diff --git a/.mise.toml b/.mise.toml index 7656606cfe..7f116d72d0 100644 --- a/.mise.toml +++ b/.mise.toml @@ -133,7 +133,7 @@ run = "bash ./scripts/dev/minio-init.sh" [tasks."dev:medusa:meili:reseed"] description = "Rebuild Medusa search documents in the running local stack" -run = "bash ./scripts/dev/run-compose.sh exec medusa-be pnpm --filter medusa-be run addInitialSearchDocuments" +run = "bash ./scripts/dev/run-compose.sh exec medusa-be pnpm --filter medusa-be run rebuildSearchDocuments" [tasks."dev:medusa:seed"] description = "Seed initial Medusa data in the running local stack" diff --git a/apps/herbatika/src/app/p/[handle]/page.tsx b/apps/herbatika/src/app/p/[handle]/page.tsx index 691abcc458..ddfaf05b40 100644 --- a/apps/herbatika/src/app/p/[handle]/page.tsx +++ b/apps/herbatika/src/app/p/[handle]/page.tsx @@ -6,18 +6,27 @@ type ProductDetailPageProps = { params: Promise<{ handle: string }> + searchParams: Promise<{ + variant?: string | string[] + }> } export default async function ProductDetailPage({ params, + searchParams, }: ProductDetailPageProps) { const { handle } = await params + const resolvedSearchParams = await searchParams + const initialVariantId = + typeof resolvedSearchParams.variant === "string" + ? resolvedSearchParams.variant + : undefined const { dehydratedState } = await prefetchProductDetailPageStorefrontData(handle) return ( - + ) } diff --git a/apps/herbatika/src/components/herbatika-product-card.shared.ts b/apps/herbatika/src/components/herbatika-product-card.shared.ts index e1d067eb8b..1b02c960af 100644 --- a/apps/herbatika/src/components/herbatika-product-card.shared.ts +++ b/apps/herbatika/src/components/herbatika-product-card.shared.ts @@ -7,6 +7,10 @@ import { PRODUCT_FALLBACK_IMAGE } from "@/components/product-card/product-card.c import { resolvePriceState } from "@/components/product-card/product-card.pricing" import { resolveThumbnail } from "@/components/product-card/product-card.thumbnail" import { resolveRegionCurrency } from "@/lib/storefront/region-selection" +import { + asStorefrontRecord, + asStorefrontString, +} from "@/lib/storefront/product-pricing" export type HerbatikaProductCardBaseProps = { product: HttpTypes.StoreProduct @@ -25,11 +29,23 @@ export function useHerbatikaProductCardState( ) { const region = useRegionContext() const currencyCode = resolveRegionCurrency(region) - const productHref = product.handle ? `/p/${product.handle}` : "/#" + const productRecord = asStorefrontRecord(product) + const searchResult = asStorefrontRecord(productRecord?.search_result) + const searchResultVariantId = asStorefrontString(searchResult?.variant_id) + const searchResultVariantTitle = asStorefrontString( + searchResult?.variant_title + ) + const productHref = product.handle + ? `/p/${product.handle}${searchResultVariantId ? `?variant=${encodeURIComponent(searchResultVariantId)}` : ""}` + : "/#" const price = resolvePriceState(product, currencyCode, priceUnavailableLabel) const thumbnail = resolveThumbnail(product) const [imageSrc, setImageSrc] = useState(thumbnail) - const title = product.title?.trim() || product.handle?.trim() || product.id + const productTitle = + product.title?.trim() || product.handle?.trim() || product.id + const title = searchResultVariantTitle + ? `${productTitle} – ${searchResultVariantTitle}` + : productTitle useEffect(() => { setImageSrc(thumbnail) diff --git a/apps/herbatika/src/components/product-detail.tsx b/apps/herbatika/src/components/product-detail.tsx index a29268f057..e565909a87 100644 --- a/apps/herbatika/src/components/product-detail.tsx +++ b/apps/herbatika/src/components/product-detail.tsx @@ -22,9 +22,9 @@ import { useProductDetailController } from "@/components/product-detail/use-prod import { RecentlyVisitedProductsSection } from "@/components/recently-visited-products-section" import { runDetachedPromise } from "@/lib/storefront/detached-promise" -export function ProductDetail({ handle }: ProductDetailProps) { +export function ProductDetail({ handle, initialVariantId }: ProductDetailProps) { const tCatalog = useTranslations("catalog") - const controller = useProductDetailController({ handle }) + const controller = useProductDetailController({ handle, initialVariantId }) const [activeInfoSection, setActiveInfoSection] = useState< string | undefined >(controller.defaultInfoSectionValue) diff --git a/apps/herbatika/src/components/product-detail/product-detail.types.ts b/apps/herbatika/src/components/product-detail/product-detail.types.ts index cdc8686a88..e45ee84871 100644 --- a/apps/herbatika/src/components/product-detail/product-detail.types.ts +++ b/apps/herbatika/src/components/product-detail/product-detail.types.ts @@ -6,6 +6,7 @@ export type Product = HttpTypes.StoreProduct export type ProductDetailProps = { handle: string + initialVariantId?: string } export type ProductPriceState = { diff --git a/apps/herbatika/src/components/product-detail/use-product-detail-controller.ts b/apps/herbatika/src/components/product-detail/use-product-detail-controller.ts index 07702ac83d..f5ff7b23bd 100644 --- a/apps/herbatika/src/components/product-detail/use-product-detail-controller.ts +++ b/apps/herbatika/src/components/product-detail/use-product-detail-controller.ts @@ -5,12 +5,14 @@ import { useProductDetailData } from "@/components/product-detail/use-product-de type UseProductDetailControllerProps = { handle: string + initialVariantId?: string } export function useProductDetailController({ handle, + initialVariantId, }: UseProductDetailControllerProps) { - const data = useProductDetailData({ handle }) + const data = useProductDetailData({ handle, initialVariantId }) const actions = useProductDetailActions({ product: data.product, quantity: data.quantity, diff --git a/apps/herbatika/src/components/product-detail/use-product-detail-data.ts b/apps/herbatika/src/components/product-detail/use-product-detail-data.ts index 8e7e1bc705..99db302aab 100644 --- a/apps/herbatika/src/components/product-detail/use-product-detail-data.ts +++ b/apps/herbatika/src/components/product-detail/use-product-detail-data.ts @@ -42,9 +42,15 @@ import { useRecordRecentlyVisitedProduct } from "@/lib/storefront/recently-visit import { resolveRegionCurrency } from "@/lib/storefront/region-selection" import { storefront } from "@/lib/storefront/storefront" -type UseProductDetailDataProps = { handle: string } +type UseProductDetailDataProps = { + handle: string + initialVariantId?: string +} -export function useProductDetailData({ handle }: UseProductDetailDataProps) { +export function useProductDetailData({ + handle, + initialVariantId, +}: UseProductDetailDataProps) { const tCatalog = useTranslations("catalog") const tNavigation = useTranslations("navigation") const region = useRegionContext() @@ -179,9 +185,13 @@ export function useProductDetailData({ handle }: UseProductDetailDataProps) { useEffect(() => { setQuantity(1) - setSelectedVariantId(product?.variants?.[0]?.id ?? null) + setSelectedVariantId( + product?.variants?.some((variant) => variant.id === initialVariantId) + ? (initialVariantId ?? null) + : (product?.variants?.[0]?.id ?? null) + ) setSelectedVolumeDiscountId(null) - }, [product?.variants]) + }, [initialVariantId, product?.variants]) useEffect(() => { if (availableQuantity === null || availableQuantity < 1) { diff --git a/apps/herbatika/src/components/search-results.tsx b/apps/herbatika/src/components/search-results.tsx index 53a353fb58..2e12fa6fb4 100644 --- a/apps/herbatika/src/components/search-results.tsx +++ b/apps/herbatika/src/components/search-results.tsx @@ -7,12 +7,21 @@ import { CategoryFacetsPanel } from "@/components/category/category-facets-panel import { CategoryResultsSection } from "@/components/category/category-results-section" import { RecentlyVisitedProductsSection } from "@/components/recently-visited-products-section" import { PLP_PAGE_SIZE } from "@/lib/storefront/plp-query-state" +import { SearchEntityResults } from "./search/search-entity-results" +import { useSearchAutocomplete } from "./search/use-search-autocomplete" import { useSearchListingController } from "./search/use-search-listing-controller" export function SearchResults() { const t = useTranslations("search") const controller = useSearchListingController() const safeTotalPages = Math.max(controller.catalogQuery.totalPages, 1) + const autocomplete = useSearchAutocomplete({ + countryCode: controller.searchCountryCode, + query: controller.query, + currencyCode: controller.productsCurrencyCode, + enabled: controller.isSearchQueryEnabled, + regionId: controller.searchRegionId, + }) return (
@@ -43,51 +52,64 @@ export function SearchResults() { ) : null} {controller.query ? ( - - } - results={ - + + +
+

+ {t("autocomplete.sections.products")} +

+ + } + results={ + + } /> - } - /> +
+ ) : (

diff --git a/apps/herbatika/src/components/search/search-autocomplete-media.tsx b/apps/herbatika/src/components/search/search-autocomplete-media.tsx index fd594d82a8..ca495fde4c 100644 --- a/apps/herbatika/src/components/search/search-autocomplete-media.tsx +++ b/apps/herbatika/src/components/search/search-autocomplete-media.tsx @@ -13,6 +13,7 @@ const TYPE_ICON: Record< > = { brand: "token-icon-label", category: "token-icon-box", + content: "token-icon-file-document", } export function SearchAutocompleteMedia({ diff --git a/apps/herbatika/src/components/search/search-autocomplete-sections.ts b/apps/herbatika/src/components/search/search-autocomplete-sections.ts index be5f234c29..c6aefbad3f 100644 --- a/apps/herbatika/src/components/search/search-autocomplete-sections.ts +++ b/apps/herbatika/src/components/search/search-autocomplete-sections.ts @@ -4,6 +4,7 @@ import type { SearchAutocompletePanelSection } from "./search-autocomplete-panel type SearchAutocompleteSectionTitles = { brands: string categories: string + content: string products: string } @@ -14,6 +15,7 @@ export const createSearchAutocompleteSections = ( { key: "product", title: titles.products, items: data.products }, { key: "category", title: titles.categories, items: data.categories }, { key: "brand", title: titles.brands, items: data.brands }, + { key: "content", title: titles.content, items: data.content }, ] export const clampSearchAutocompleteIndex = ( diff --git a/apps/herbatika/src/components/search/search-entity-results.tsx b/apps/herbatika/src/components/search/search-entity-results.tsx new file mode 100644 index 0000000000..cc1f667592 --- /dev/null +++ b/apps/herbatika/src/components/search/search-entity-results.tsx @@ -0,0 +1,94 @@ +'use client' + +import { Link } from '@techsio/ui-kit/atoms/link' +import NextLink from 'next/link' +import type { SearchAutocompleteSuggestion, SearchAutocompleteResponse } from '@/lib/search-autocomplete/search-autocomplete-types' +import { SearchAutocompleteMedia } from './search-autocomplete-media' + +type SearchEntityResultSectionProps = { + items: SearchAutocompleteSuggestion[] + title: string +} + +type SearchEntityResultsProps = Pick + +function SearchEntityResultSection({ items, title }: SearchEntityResultSectionProps) { + if (items.length === 0) { + return null + } + + return ( +

+

+ { title } +

+ +
    + { + items.map((item) => ( +
  • + + + + + + { item.title } + + + { + item.subtitle ? ( + + { item.subtitle } + + ) : null + } + + +
  • + )) + } +
+
+ ) +} + +export function SearchEntityResults({ brands, categories, content }: SearchEntityResultsProps) { + if (brands.length + categories.length + content.length === 0) { + return null + } + + return ( +
+ + + + + + + +
+ ) +} diff --git a/apps/herbatika/src/components/search/use-search-autocomplete-controller.ts b/apps/herbatika/src/components/search/use-search-autocomplete-controller.ts index d27426ad0d..330218bb82 100644 --- a/apps/herbatika/src/components/search/use-search-autocomplete-controller.ts +++ b/apps/herbatika/src/components/search/use-search-autocomplete-controller.ts @@ -44,6 +44,7 @@ export function useSearchAutocompleteController({ const autocomplete = useSearchAutocomplete({ countryCode, currencyCode, + enabled: isFocused && !isDismissed, query: value, regionId, }) @@ -51,14 +52,18 @@ export function useSearchAutocompleteController({ const sections = createSearchAutocompleteSections(autocomplete.data, { brands: t("autocomplete.sections.brands"), categories: t("autocomplete.sections.categories"), + content: t("autocomplete.sections.content"), products: t("autocomplete.sections.products"), }) const flatItems = sections.flatMap((section) => section.items) const activeItem = flatItems[activeIndex] ?? null + const hasSearchableQuery = + normalizedQuery.length === 0 || + normalizedQuery.length >= SEARCH_AUTOCOMPLETE_MIN_QUERY_LENGTH const shouldShowPanel = isFocused && !isDismissed && - normalizedQuery.length >= SEARCH_AUTOCOMPLETE_MIN_QUERY_LENGTH && + hasSearchableQuery && autocomplete.status !== "idle" const activeItemId = shouldShowPanel && activeItem diff --git a/apps/herbatika/src/components/search/use-search-autocomplete.ts b/apps/herbatika/src/components/search/use-search-autocomplete.ts index bbe071eecc..eeb1b96ca6 100644 --- a/apps/herbatika/src/components/search/use-search-autocomplete.ts +++ b/apps/herbatika/src/components/search/use-search-autocomplete.ts @@ -14,6 +14,7 @@ type UseSearchAutocompleteInput = { countryCode?: string query: string currencyCode: string + enabled: boolean regionId?: string } @@ -26,6 +27,7 @@ export function useSearchAutocomplete({ countryCode, query, currencyCode, + enabled, regionId, }: UseSearchAutocompleteInput): UseSearchAutocompleteResult { const normalizedQuery = query @@ -37,7 +39,16 @@ export function useSearchAutocomplete({ const [status, setStatus] = useState("idle") useEffect(() => { - if (normalizedQuery.length < SEARCH_AUTOCOMPLETE_MIN_QUERY_LENGTH) { + if (!enabled) { + setData(createEmptySearchAutocompleteResponse(normalizedQuery)) + setStatus("idle") + return + } + + if ( + normalizedQuery.length > 0 && + normalizedQuery.length < SEARCH_AUTOCOMPLETE_MIN_QUERY_LENGTH + ) { setData(createEmptySearchAutocompleteResponse(normalizedQuery)) setStatus("idle") return @@ -90,7 +101,7 @@ export function useSearchAutocomplete({ window.clearTimeout(timeoutId) abortController.abort() } - }, [countryCode, currencyCode, normalizedQuery, regionId]) + }, [countryCode, currencyCode, enabled, normalizedQuery, regionId]) return { data, status } } diff --git a/apps/herbatika/src/components/search/use-search-listing-controller.ts b/apps/herbatika/src/components/search/use-search-listing-controller.ts index 03ab98f3d6..f0df671c27 100644 --- a/apps/herbatika/src/components/search/use-search-listing-controller.ts +++ b/apps/herbatika/src/components/search/use-search-listing-controller.ts @@ -105,5 +105,7 @@ export function useSearchListingController() { products: catalogQuery.products, productsCurrencyCode: regionCurrencyCode, query, + searchCountryCode: region?.country_code, + searchRegionId: region?.region_id, } } diff --git a/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-content-normalizers.ts b/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-content-normalizers.ts new file mode 100644 index 0000000000..3d7ba24f60 --- /dev/null +++ b/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-content-normalizers.ts @@ -0,0 +1,23 @@ +import { normalizeString } from './search-autocomplete-normalizers' +import type { RawSearchAutocompleteContentHit, SearchAutocompleteSuggestion } from './search-autocomplete-types' + +const createContentSuggestion = (hit: RawSearchAutocompleteContentHit): SearchAutocompleteSuggestion | null => { + const id = normalizeString(hit.id) + const title = normalizeString(hit.title) + const href = normalizeString(hit.href) + const type = normalizeString(hit.type) + + if (!id || !title || !href) { + return null + } + + return { + id: id, + type: 'content', + title: title, + href: href, + subtitle: type === 'article' ? 'Článok' : normalizeString(hit.excerpt) || 'Informačná stránka' + } +} + +export const createContentSuggestions = (hits: RawSearchAutocompleteContentHit[]) => hits.map(createContentSuggestion).filter((item): item is SearchAutocompleteSuggestion => Boolean(item)) diff --git a/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-product-normalizers.ts b/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-product-normalizers.ts index a0685be769..d6573acc69 100644 --- a/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-product-normalizers.ts +++ b/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-product-normalizers.ts @@ -36,13 +36,16 @@ const createProductSuggestion = ( currencyCode: HerbatikaCurrencyCode ): SearchAutocompleteSuggestion | null => { const id = normalizeString(hit.id) - const title = normalizeString(hit.title) + const productTitle = normalizeString(hit.title) const handle = normalizeString(hit.handle) - if (!(id && title && handle)) { + if (!(id && productTitle && handle)) { return null } + const variantId = normalizeString(hit.search_result?.variant_id) + const variantTitle = normalizeString(hit.search_result?.variant_title) + const title = variantTitle ? `${productTitle} – ${variantTitle}` : productTitle const brandTitle = normalizeString(hit.brand?.title) const firstCategory = hit.categories?.find((category) => Boolean(normalizeString(category.name)) @@ -51,10 +54,10 @@ const createProductSuggestion = ( const price = resolveProductPrice(hit, currencyCode) return { - id, + id: variantId ? `${id}-${variantId}` : id, type: "product", title, - href: `/p/${handle}`, + href: `/p/${handle}${variantId ? `?variant=${encodeURIComponent(variantId)}` : ""}`, subtitle: [brandTitle, categoryName].filter(Boolean).join(" | "), imageUrl: normalizeString(hit.thumbnail) || undefined, priceLabel: price @@ -67,7 +70,7 @@ const createProductSuggestion = ( export const createProductSuggestions = ( hits: RawSearchAutocompleteProductHit[], currencyCode: HerbatikaCurrencyCode, - limit: number + limit = hits.length ) => hits .map((hit) => createProductSuggestion(hit, currencyCode)) diff --git a/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-taxonomy-normalizers.ts b/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-taxonomy-normalizers.ts index 576d7346c6..611427febe 100644 --- a/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-taxonomy-normalizers.ts +++ b/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-taxonomy-normalizers.ts @@ -1,30 +1,15 @@ import { createBrandHref } from "@/lib/storefront/brands" import { createHandleLabel, - normalizeComparable, normalizeString, resolveBrandSlug, } from "./search-autocomplete-normalizers" import type { RawSearchAutocompleteBrandRef, RawSearchAutocompleteCategoryRef, - RawSearchAutocompleteFacetItem, - RawSearchAutocompleteProductHit, SearchAutocompleteSuggestion, } from "./search-autocomplete-types" -const matchesQuery = (values: unknown[], query: string) => { - const comparableQuery = normalizeComparable(query) - return values.some((value) => - normalizeComparable(normalizeString(value)).includes(comparableQuery) - ) -} - -const categoryMatchesQuery = ( - category: RawSearchAutocompleteCategoryRef, - query: string -) => matchesQuery([category.name, category.handle], query) - const createCategorySuggestion = ( category: RawSearchAutocompleteCategoryRef ): SearchAutocompleteSuggestion | null => { @@ -45,41 +30,13 @@ const createCategorySuggestion = ( } export const createCategorySuggestions = ({ - productHits, - query, - limit, + categoryHits, }: { - productHits: RawSearchAutocompleteProductHit[] - query: string - limit: number -}) => { - const suggestions: SearchAutocompleteSuggestion[] = [] - const seen = new Set() - - const pushSuggestion = (suggestion: SearchAutocompleteSuggestion | null) => { - if (!suggestion || seen.has(suggestion.href)) { - return - } - - seen.add(suggestion.href) - suggestions.push(suggestion) - } - - for (const product of productHits) { - for (const category of product.categories ?? []) { - if (categoryMatchesQuery(category, query)) { - pushSuggestion(createCategorySuggestion(category)) - } - } - } - - return suggestions.slice(0, limit) -} - -const brandMatchesQuery = ( - brand: RawSearchAutocompleteBrandRef, - query: string -) => matchesQuery([brand.title, brand.handle], query) + categoryHits: RawSearchAutocompleteCategoryRef[] +}) => + categoryHits + .map(createCategorySuggestion) + .filter((item): item is SearchAutocompleteSuggestion => Boolean(item)) const createBrandSuggestion = ( brand: RawSearchAutocompleteBrandRef @@ -101,74 +58,11 @@ const createBrandSuggestion = ( } } -const createBrandSuggestionFromFacet = ( - facet: RawSearchAutocompleteFacetItem -): SearchAutocompleteSuggestion | null => { - const id = normalizeString(facet.id) - const title = normalizeString(facet.label) - const slug = id.startsWith("brand-") - ? id.slice("brand-".length) - : resolveBrandSlug(id, title) - - if (!(id && title && slug)) { - return null - } - - return { - id, - type: "brand", - title, - href: createBrandHref({ slug }), - } -} - -const pushUniqueSuggestion = ( - suggestions: SearchAutocompleteSuggestion[], - seen: Set, - suggestion: SearchAutocompleteSuggestion | null -) => { - if (!suggestion || seen.has(suggestion.href)) { - return - } - - seen.add(suggestion.href) - suggestions.push(suggestion) -} - export const createBrandSuggestions = ({ - brandFacets, - productHits, - query, - limit, + brandHits, }: { - brandFacets: RawSearchAutocompleteFacetItem[] - productHits: RawSearchAutocompleteProductHit[] - query: string - limit: number -}) => { - const suggestions: SearchAutocompleteSuggestion[] = [] - const seen = new Set() - - for (const facet of brandFacets) { - if (!matchesQuery([facet.id, facet.label], query)) { - continue - } - - pushUniqueSuggestion( - suggestions, - seen, - createBrandSuggestionFromFacet(facet) - ) - } - - for (const product of productHits) { - const brand = product.brand - if (!(brand && brandMatchesQuery(brand, query))) { - continue - } - - pushUniqueSuggestion(suggestions, seen, createBrandSuggestion(brand)) - } - - return suggestions.slice(0, limit) -} + brandHits: RawSearchAutocompleteBrandRef[] +}) => + brandHits + .map(createBrandSuggestion) + .filter((item): item is SearchAutocompleteSuggestion => Boolean(item)) diff --git a/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-types.ts b/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-types.ts index 650077c292..f90baab84e 100644 --- a/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-types.ts +++ b/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-types.ts @@ -1,4 +1,8 @@ -export type SearchAutocompleteSuggestionType = "product" | "category" | "brand" +export type SearchAutocompleteSuggestionType = + | "product" + | "category" + | "brand" + | "content" export type SearchAutocompleteSuggestion = { id: string @@ -16,6 +20,7 @@ export type SearchAutocompleteResponse = { products: SearchAutocompleteSuggestion[] categories: SearchAutocompleteSuggestion[] brands: SearchAutocompleteSuggestion[] + content: SearchAutocompleteSuggestion[] } export type SearchAutocompleteStatus = "idle" | "loading" | "success" | "error" @@ -38,6 +43,14 @@ export type RawSearchAutocompleteBrandRef = { handle?: unknown } +export type RawSearchAutocompleteContentHit = { + id?: unknown + type?: unknown + title?: unknown + excerpt?: unknown + href?: unknown +} + export type RawSearchAutocompleteCalculatedPrice = { calculated_amount?: unknown currency_code?: unknown @@ -49,9 +62,19 @@ export type RawSearchAutocompleteProductHit = { handle?: unknown thumbnail?: unknown metadata?: unknown + search_result?: { + variant_id?: unknown + variant_title?: unknown + } brand?: RawSearchAutocompleteBrandRef categories?: RawSearchAutocompleteCategoryRef[] variants?: Array<{ + id?: unknown + title?: unknown + sku?: unknown + ean?: unknown + upc?: unknown + barcode?: unknown calculated_price?: RawSearchAutocompleteCalculatedPrice }> } @@ -67,4 +90,5 @@ export const createEmptySearchAutocompleteResponse = ( products: [], categories: [], brands: [], + content: [], }) diff --git a/apps/herbatika/src/lib/search-autocomplete/search-autocomplete.server.ts b/apps/herbatika/src/lib/search-autocomplete/search-autocomplete.server.ts index 3b6212759b..fff1244b99 100644 --- a/apps/herbatika/src/lib/search-autocomplete/search-autocomplete.server.ts +++ b/apps/herbatika/src/lib/search-autocomplete/search-autocomplete.server.ts @@ -6,6 +6,7 @@ import { MEDUSA_PUBLISHABLE_KEY, } from "@/lib/storefront/ssr/constants" import { normalizeString } from "./search-autocomplete-normalizers" +import { createContentSuggestions } from "./search-autocomplete-content-normalizers" import { createProductSuggestions } from "./search-autocomplete-product-normalizers" import { createBrandSuggestions, @@ -13,7 +14,9 @@ import { } from "./search-autocomplete-taxonomy-normalizers" import { createEmptySearchAutocompleteResponse, - type RawSearchAutocompleteFacetItem, + type RawSearchAutocompleteBrandRef, + type RawSearchAutocompleteCategoryRef, + type RawSearchAutocompleteContentHit, type RawSearchAutocompleteProductHit, SEARCH_AUTOCOMPLETE_MAX_QUERY_LENGTH, SEARCH_AUTOCOMPLETE_MIN_QUERY_LENGTH, @@ -21,9 +24,9 @@ import { } from "./search-autocomplete-types" type CatalogAutocompleteResponse = { - facets?: { - brand?: RawSearchAutocompleteFacetItem[] - } + brands?: RawSearchAutocompleteBrandRef[] + categories?: RawSearchAutocompleteCategoryRef[] + content?: RawSearchAutocompleteContentHit[] products?: RawSearchAutocompleteProductHit[] } @@ -34,10 +37,6 @@ type FetchSearchAutocompleteInput = { regionId?: string | null } -const PRODUCT_LIMIT = 5 -const CATEGORY_LIMIT = 5 -const BRAND_LIMIT = 4 -const CANDIDATE_LIMIT = 12 const CATALOG_FETCH_TIMEOUT_MS = 3000 const normalizeSearchAutocompleteQuery = (query: string) => @@ -54,11 +53,8 @@ const createCatalogAutocompleteUrl = ({ query: string regionId?: string | null }) => { - const url = new URL("/store/catalog/products", MEDUSA_BACKEND_URL) + const url = new URL("/store/search/autocomplete", MEDUSA_BACKEND_URL) url.searchParams.set("q", query) - url.searchParams.set("page", "1") - url.searchParams.set("limit", String(CANDIDATE_LIMIT)) - url.searchParams.set("sort", "recommended") url.searchParams.set("currency_code", currencyCode.toLowerCase()) const normalizedRegionId = normalizeString(regionId) @@ -138,7 +134,10 @@ export const fetchSearchAutocomplete = async ({ regionId, }: FetchSearchAutocompleteInput): Promise => { const normalizedQuery = normalizeSearchAutocompleteQuery(query) - if (normalizedQuery.length < SEARCH_AUTOCOMPLETE_MIN_QUERY_LENGTH) { + if ( + normalizedQuery.length > 0 && + normalizedQuery.length < SEARCH_AUTOCOMPLETE_MIN_QUERY_LENGTH + ) { return createEmptySearchAutocompleteResponse(normalizedQuery) } @@ -153,21 +152,13 @@ export const fetchSearchAutocomplete = async ({ return { query: normalizedQuery, - products: createProductSuggestions( - productHits, - safeCurrencyCode, - PRODUCT_LIMIT - ), + products: createProductSuggestions(productHits, safeCurrencyCode), categories: createCategorySuggestions({ - productHits, - query: normalizedQuery, - limit: CATEGORY_LIMIT, + categoryHits: catalogResponse.categories ?? [], }), brands: createBrandSuggestions({ - brandFacets: catalogResponse.facets?.brand ?? [], - productHits, - query: normalizedQuery, - limit: BRAND_LIMIT, + brandHits: catalogResponse.brands ?? [], }), + content: createContentSuggestions(catalogResponse.content ?? []), } } diff --git a/apps/medusa-be/package.json b/apps/medusa-be/package.json index dddc415f7e..5445ab6d30 100644 --- a/apps/medusa-be/package.json +++ b/apps/medusa-be/package.json @@ -49,6 +49,7 @@ "seedDefaultData": "medusa exec ./src/scripts/seed-products-from-db.ts", "seedDefaultDataTs": "medusa exec ./src/scripts/seed-products-from-db.ts", "addInitialSearchDocuments": "medusa exec ./src/scripts/search-index.ts", + "rebuildSearchDocuments": "medusa exec ./src/scripts/search-index.ts -- --full", "setInitialPublishableKey": "bash -euo pipefail -c 'PUBLISHABLE_KEY_TITLE=\"${INITIAL_PUBLISHABLE_KEY_NAME:-Storefront Publishable Key}\"; if [ -f ./src/scripts/output-publishable-key.ts ]; then medusa exec ./src/scripts/output-publishable-key.ts \"$PUBLISHABLE_KEY_TITLE\"; else medusa exec ./src/scripts/output-publishable-key.js \"$PUBLISHABLE_KEY_TITLE\"; fi | grep -o \".*\" | sed \"s///g;s/<\\\\/PK_TOKEN>//g\" | tr -d \"\\n\\r\"'", "setInitialPublishableKeyZerops": "bash -euo pipefail -c \"npm run -s setInitialPublishableKey | tr -d '\\n\\r' | zsc setSecretEnv CHANNEL_PUBLISHABLE_KEY\"", "seedHerbatica": "medusa exec ./src/scripts/herbatica-seed.ts", diff --git a/apps/medusa-be/src/admin/lib/search-profiles.ts b/apps/medusa-be/src/admin/lib/search-profiles.ts new file mode 100644 index 0000000000..0cc2a26ddb --- /dev/null +++ b/apps/medusa-be/src/admin/lib/search-profiles.ts @@ -0,0 +1,177 @@ +import { sdk as medusaClient } from './sdk' + +export type SearchIndexType = 'product' | 'category' | 'brand' | 'content' +export type SearchSyncMode = 'normal' | 'full' + +export type SearchProfile = { + id: string + key: string + shop: string + domain: string + locale: string + sales_channel_ids: string[] + strict: boolean + separate_variant_results: boolean + minimum_ranking_score: number | null + effective_minimum_ranking_score: number + availability: 'all' | 'in-stock' + autocomplete_product_limit: number + autocomplete_category_limit: number + autocomplete_brand_limit: number + autocomplete_content_limit: number + full_search_limit: number + max_results_per_page: number + popular_limit: number + last_sync_status: 'never' | 'running' | 'succeeded' | 'failed' + last_sync_mode: SearchSyncMode | null + last_sync_started_at: string | null + last_synced_at: string | null + last_sync_error: string | null + last_indexed_count: number + last_deleted_count: number + created_at: string + updated_at: string +} + +export type SearchProfileInput = { + key: string + shop: string + domain: string + locale: string + sales_channel_ids: string[] + strict: boolean + separate_variant_results: boolean + minimum_ranking_score: number | null + availability: 'all' | 'in-stock' + autocomplete_product_limit: number + autocomplete_category_limit: number + autocomplete_brand_limit: number + autocomplete_content_limit: number + full_search_limit: number + max_results_per_page: number + popular_limit: number +} + +export type SalesChannelOption = { + id: string + name: string +} + +export type SearchTestResult = { + profile: string + type: SearchIndexType + query: string + minimum_ranking_score: number | null + hits: Record[] + raw_hit_count: number + processing_time_ms: number | null +} + +export type SearchTestInput = { + query: string + type: SearchIndexType + limit: number +} + +export type VectorStatus = { + enabled: boolean + provider?: string + model?: string + dimensions?: number + embeddingFields?: string[] +} + +export type MeilisearchStatus = { + enabled: boolean + connected: boolean + status: string + error?: string +} + +export const searchProfileQueryKeys = { + all: ['search-profiles'] as const, + list: () => ['search-profiles', 'list'] as const, + salesChannels: () => ['search-profiles', 'sales-channels'] as const, + vectorStatus: () => ['search-profiles', 'vector-status'] as const, + status: () => ['search-profiles', 'status'] as const +} + +export const listSearchProfiles = async (): Promise => { + const response = await medusaClient.client.fetch<{ profiles: SearchProfile[] }>('/admin/search-profiles') + + return response.profiles +} + +export const listSalesChannels = async (): Promise => { + const response = await medusaClient.client.fetch<{ sales_channels: SalesChannelOption[] }>('/admin/sales-channels', { query: { fields: 'id,name', limit: 100 } }) + + return response.sales_channels +} + +export const createSearchProfile = async (input: SearchProfileInput): Promise => { + const response = await medusaClient.client.fetch<{ profile: SearchProfile }>('/admin/search-profiles', { method: 'POST', body: input }) + + return response.profile +} + +export const updateSearchProfile = async (id: string, input: SearchProfileInput): Promise => { + const response = await medusaClient.client.fetch<{ profile: SearchProfile }>('/admin/search-profiles/' + id, { method: 'POST', body: input }) + + return response.profile +} + +export const deleteSearchProfile = async (id: string): Promise => { + await medusaClient.client.fetch('/admin/search-profiles/' + id, { method: 'DELETE' }) +} + +export const synchronizeSearchProfiles = async (mode: SearchSyncMode, id?: string) => medusaClient.client.fetch<{ result: { deleted: number; indexed: number; mode: SearchSyncMode; profiles: number } }>(id ? '/admin/search-profiles/' + id + '/sync' : '/admin/search-profiles/sync', { method: 'POST', body: { mode } }) + +export const testSearchProfile = async (id: string, input: SearchTestInput): Promise => medusaClient.client.fetch('/admin/search-profiles/' + id + '/test', { method: 'POST', body: input }) + +export const getVectorStatus = async (): Promise => medusaClient.client.fetch('/admin/meilisearch/vector-status') + +export const getMeilisearchStatus = async (): Promise => medusaClient.client.fetch('/admin/search-profiles/status') + +const DEFAULT_SEARCH_PROFILE_INPUT: SearchProfileInput = { + key: '', + shop: '', + domain: '', + locale: '', + sales_channel_ids: [], + strict: false, + separate_variant_results: false, + minimum_ranking_score: null, + availability: 'all', + autocomplete_product_limit: 6, + autocomplete_category_limit: 3, + autocomplete_brand_limit: 3, + autocomplete_content_limit: 3, + full_search_limit: 500, + max_results_per_page: 100, + popular_limit: 12 +} + +export const toSearchProfileInput = (profile?: SearchProfile): SearchProfileInput => { + if (!profile) { + return { ...DEFAULT_SEARCH_PROFILE_INPUT } + } + + return { + key: profile.key, + shop: profile.shop, + domain: profile.domain, + locale: profile.locale, + sales_channel_ids: profile.sales_channel_ids, + strict: profile.strict, + separate_variant_results: profile.separate_variant_results, + minimum_ranking_score: profile.minimum_ranking_score, + availability: profile.availability, + autocomplete_product_limit: profile.autocomplete_product_limit, + autocomplete_category_limit: profile.autocomplete_category_limit, + autocomplete_brand_limit: profile.autocomplete_brand_limit, + autocomplete_content_limit: profile.autocomplete_content_limit, + full_search_limit: profile.full_search_limit, + max_results_per_page: profile.max_results_per_page, + popular_limit: profile.popular_limit + } +} diff --git a/apps/medusa-be/src/admin/routes/settings/meilisearch/components/search-profile-form.tsx b/apps/medusa-be/src/admin/routes/settings/meilisearch/components/search-profile-form.tsx new file mode 100644 index 0000000000..53877ef42e --- /dev/null +++ b/apps/medusa-be/src/admin/routes/settings/meilisearch/components/search-profile-form.tsx @@ -0,0 +1,553 @@ +import { Button, Checkbox, FocusModal, Heading, Input, Label, Select, Switch, Text } from '@medusajs/ui' +import type { Dispatch, FormEvent, SetStateAction } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { type SalesChannelOption, type SearchIndexType, type SearchProfile, type SearchProfileInput, toSearchProfileInput } from '../../../../lib/search-profiles' + +const INDEX_TYPES: SearchIndexType[] = ['product', 'category', 'brand', 'content'] + +type AutocompleteLimitField = 'autocomplete_product_limit' | 'autocomplete_category_limit' | 'autocomplete_brand_limit' | 'autocomplete_content_limit' + +const AUTOCOMPLETE_LIMIT_FIELD_BY_TYPE: Record = { + product: 'autocomplete_product_limit', + category: 'autocomplete_category_limit', + brand: 'autocomplete_brand_limit', + content: 'autocomplete_content_limit' +} + +const NumberField = ({ description, label, max, min = 1, onChange, value }: { + description: string + label: string + max: number + min?: number + onChange: (value: number) => void + value: number +}) => { + const id = 'search-profile-' + label.toLowerCase().replaceAll(' ', '-') + + return ( +
+ + + onChange(Number(event.target.value)) + } + /> + + + { description } + +
+ ) +} + +const ToggleField = ({ checked, description, label, onCheckedChange }: { + checked: boolean + description: string + label: string + onCheckedChange: (checked: boolean) => void +}) => ( +
+
+ + { label } + + + + { description } + +
+ + +
+) + +const normalizeProfileKeySegment = (value: string) => value.trim().toLowerCase().normalize('NFKD').replaceAll(/\p{Diacritic}/gu, '').replaceAll(/[^a-z0-9_-]+/g, '-').replaceAll(/-+/g, '-').replaceAll(/^-|-$/g, '') +const generatedProfileKey = (form: SearchProfileInput) => [form.shop, form.domain, form.locale].map(normalizeProfileKeySegment).filter(Boolean).join('-') + +const SearchProfileFields = ({ form, salesChannels, setForm }: { + form: SearchProfileInput + salesChannels: SalesChannelOption[] + setForm: Dispatch> +}) => { + const effectiveScore = form.minimum_ranking_score ?? (form.strict ? 0.98 : 0.55) + + const toggleSalesChannel = (id: string, checked: boolean) => { + setForm((current) => ({ + ...current, + sales_channel_ids: checked ? [...new Set([...current.sales_channel_ids, id])] : current.sales_channel_ids.filter((entry) => entry !== id) + })) + } + + return ( +
+
+
+ + Storefront scope + + + + The stable Shop, Domain ID, and Language generate the profile key. + +
+ +
+
+ + + +
+ + { + ( + [ + ['shop', 'Shop', 'herbatika'], + ['domain', 'Domain ID', '1'], + ['locale', 'Language', 'sk'] + ] as const + ).map(([field, label, placeholder]) => ( +
+ + + setForm((current) => ({ + ...current, + [field]: event.target.value.toLowerCase() + })) + } + /> +
+ )) + } +
+ +
+ +
+
+ + Sales Channels + + + + Assign the Sales Channels where this profile provides search. A profile without an assigned Sales Channel is disabled. + +
+ +
+ { + salesChannels.length ? ( + salesChannels.map((channel) => ( + + )) + ) : ( + + No Sales Channels were returned by Medusa. + + ) + } +
+
+ +
+
+ + Search behavior + + + + These settings are applied by catalog search, autocomplete, and every index rebuild. + +
+ +
+ setForm((current) => { + const scoreFollowedPreviousDefault = current.minimum_ranking_score === (current.strict ? 0.98 : 0.55) + + return { ...current, strict: strict, minimum_ranking_score: scoreFollowedPreviousDefault ? null : current.minimum_ranking_score } + }) + } + /> + + setForm((current) => ({ + ...current, + separate_variant_results: separateVariantResults + })) + } + /> +
+ +
+
+ + + +
+ +
+ + + setForm((current) => ({ + ...current, + minimum_ranking_score: event.target.value === '' ? null : Number(event.target.value) + })) + } + /> + + + Effective score: { effectiveScore }. Leave empty to follow the strict/loose default. + +
+
+
+ +
+
+ + Result limits + + + + Keep browser payloads and authoritative Medusa hydration bounded on large shops. + +
+ +
+ setForm((current) => ({ + ...current, + max_results_per_page: value + })) + } + /> + + setForm((current) => ({ + ...current, + full_search_limit: value + })) + } + /> + + setForm((current) => ({ + ...current, + popular_limit: value + })) + } + /> +
+ +
+ { + INDEX_TYPES.map((type) => { + const field = AUTOCOMPLETE_LIMIT_FIELD_BY_TYPE[type] + + return ( + setForm((current) => ({ + ...current, + [field]: value + })) + } + /> + ) + }) + } +
+
+ +
+ ) +} + +export const SearchProfileFormModal = ({ onOpenChange, onSubmit, open, profile, salesChannels, submitting }: { + onOpenChange: (open: boolean) => void + onSubmit: (input: SearchProfileInput) => void + open: boolean + profile?: SearchProfile + salesChannels: SalesChannelOption[] + submitting: boolean +}) => { + const [form, setForm] = useState(() => toSearchProfileInput(profile)) + + useEffect(() => { + if (open) { + setForm(toSearchProfileInput(profile)) + } + }, [open, profile]) + + const valid = useMemo( + () => + [generatedProfileKey(form), form.shop, form.domain, form.locale].every((value) => value.trim().length > 0) && + [ + form.autocomplete_product_limit, + form.autocomplete_category_limit, + form.autocomplete_brand_limit, + form.autocomplete_content_limit, + form.full_search_limit, + form.max_results_per_page, + form.popular_limit + ].every((value) => Number.isInteger(value) && value > 0) && + (form.minimum_ranking_score === null || (form.minimum_ranking_score >= 0 && form.minimum_ranking_score <= 1)), + + [form]) + + const handleSubmit = (event: FormEvent) => { + event.preventDefault() + + const submittedForm = { + ...form, + key: generatedProfileKey(form), + shop: form.shop.trim(), + domain: form.domain.trim(), + locale: form.locale.trim() + } + + onSubmit(submittedForm) + } + + return ( + + +
+ + + { profile ? 'Edit ' + profile.key : 'Create search profile' } + + + + +
+ +
+
+ + +
+ + + +
+
+
+
+
+ ) +} diff --git a/apps/medusa-be/src/admin/routes/settings/meilisearch/components/search-test-panel.tsx b/apps/medusa-be/src/admin/routes/settings/meilisearch/components/search-test-panel.tsx new file mode 100644 index 0000000000..eb868d0e94 --- /dev/null +++ b/apps/medusa-be/src/admin/routes/settings/meilisearch/components/search-test-panel.tsx @@ -0,0 +1,277 @@ +import { Button, Heading, Input, Label, Select, Table, Text, toast } from '@medusajs/ui' +import { useMutation } from '@tanstack/react-query' +import { useEffect, useState } from 'react' +import { type SearchIndexType, type SearchProfile, type SearchTestResult, testSearchProfile } from '../../../../lib/search-profiles' + +const INDEX_TYPES: SearchIndexType[] = ['product', 'category', 'brand', 'content'] + +const hitLabel = (hit: Record): string => { + for (const field of ['title', 'name', 'handle', 'href', 'id']) { + const value = hit[field] + + if (typeof value === 'string' && value) { + return value + } + } + + return 'Untitled result' +} + +const hitId = (hit: Record, index: number): string => { + const value = hit.id + + return typeof value === 'string' || typeof value === 'number' ? String(value) : 'result-' + index +} + +export const SearchTestPanel = ({ profiles }: { profiles: SearchProfile[] }) => { + const assignedProfiles = profiles.filter((profile) => profile.sales_channel_ids.length > 0) + const defaultProfile = assignedProfiles[0] + const [profileId, setProfileId] = useState(defaultProfile?.id ?? '') + const [type, setType] = useState('product') + const [query, setQuery] = useState('') + const [result, setResult] = useState() + + useEffect(() => { + if (!profiles.some((profile) => profile.id === profileId)) { + setProfileId(defaultProfile?.id ?? '') + } + }, [defaultProfile?.id, profileId, profiles]) + + const mutationOptions = { + mutationFn: () => testSearchProfile(profileId, { query: query, type: type, limit: 10 }), + + onError: (error: unknown) => { + toast.error(error instanceof Error ? error.message : 'Search testing failed.') + }, + + onSuccess: (data: SearchTestResult) => { + setResult(data) + toast.success('Search returned ' + data.hits.length + ' accepted result(s).') + } + } + + const mutation = useMutation(mutationOptions) + + return ( +
+
+ + Search testing + + + + Query the exact index and relevance policy selected for a storefront profile. An empty product query tests popular-product ordering. + +
+ +
+
+ + + +
+ +
+ + + +
+ +
+ + + setQuery(event.target.value) + } + + onKeyDown = { + (event) => { + if (event.key === 'Enter' && profileId) { + mutation.mutate() + } + } + } + /> +
+ + +
+ + { + result && ( +
+
+ + { result.hits.length } accepted of { result.raw_hit_count } raw hits + + + + { result.processing_time_ms !== null ? result.processing_time_ms + ' ms' : '' } + { result.minimum_ranking_score !== null ? ' · min score ' + result.minimum_ranking_score : '' } + +
+ + + + + + Result + + + + ID + + + + Ranking score + + + + Document + + + + + + { + result.hits.length ? ( + result.hits.map((hit, index) => ( + + + { hitLabel(hit) } + + + + { hitId(hit, index) } + + + + { typeof hit._rankingScore === 'number' ? hit._rankingScore.toFixed(4) : '—' } + + + +
+ Inspect + +
+															{ JSON.stringify(hit, null, 2) }
+														
+
+
+
+ )) + ) : ( + + + No accepted results. + + + + + + + ) + } +
+
+
+ ) + } +
+ ) +} diff --git a/apps/medusa-be/src/admin/routes/settings/meilisearch/page.tsx b/apps/medusa-be/src/admin/routes/settings/meilisearch/page.tsx new file mode 100644 index 0000000000..9acd88f65d --- /dev/null +++ b/apps/medusa-be/src/admin/routes/settings/meilisearch/page.tsx @@ -0,0 +1,505 @@ +import { defineRouteConfig } from '@medusajs/admin-sdk' +import { MagnifyingGlass, PencilSquare, Plus, Trash } from '@medusajs/icons' +import { Badge, Button, Container, Heading, IconButton, StatusBadge, Table, Text, toast, usePrompt } from '@medusajs/ui' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { createSearchProfile, deleteSearchProfile, getMeilisearchStatus, listSalesChannels, listSearchProfiles, type SearchProfile, type SearchProfileInput, type SearchSyncMode, searchProfileQueryKeys, synchronizeSearchProfiles, updateSearchProfile } from '../../../lib/search-profiles' +import { SearchProfileFormModal } from './components/search-profile-form' +import { SearchTestPanel } from './components/search-test-panel' + +export const handle = { + breadcrumb: () => 'Meilisearch' +} + +const formatDate = (value: string | null): string => { + if (!value) { + return 'Never' + } + + return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value)) +} + +const syncStatusColor = (status: SearchProfile['last_sync_status']): 'green' | 'red' | 'orange' | 'grey' => { + if (status === 'succeeded') { + return 'green' + } + + if (status === 'failed') { + return 'red' + } + + if (status === 'running') { + return 'orange' + } + + return 'grey' +} + +const ProfileBadges = ({ profile }: { profile: SearchProfile }) => ( +
+ + { profile.strict ? 'Strict' : 'Loose' } + + + + { profile.separate_variant_results ? 'Separate variants' : 'Grouped variants' } + +
+) + +const getMeilisearchStatusBadge = (options: { + connected?: boolean + enabled?: boolean + loading: boolean +}): { + color: 'green' | 'red' | 'grey' + label: string +} => { + if (options.loading) { + return { color: 'grey', label: 'Checking' } + } + + if (options.connected) { + return { color: 'green', label: 'Connected' } + } + + if (options.enabled) { + return { color: 'red', label: 'Unavailable' } + } + + return { color: 'grey', label: 'Disabled' } +} + +const MeilisearchSettingsPage = () => { + const queryClient = useQueryClient() + const prompt = usePrompt() + const [formOpen, setFormOpen] = useState(false) + const [editingProfile, setEditingProfile] = useState() + const [syncTarget, setSyncTarget] = useState() + const profilesQuery = useQuery({ queryFn: listSearchProfiles, queryKey: searchProfileQueryKeys.list() }) + const salesChannelsQuery = useQuery({ queryFn: listSalesChannels, queryKey: searchProfileQueryKeys.salesChannels() }) + const statusQuery = useQuery({ queryFn: getMeilisearchStatus, queryKey: searchProfileQueryKeys.status(), retry: 1 }) + + const refreshProfiles = async () => { + await queryClient.invalidateQueries({ queryKey: searchProfileQueryKeys.all }) + } + + const saveMutationOptions = { + mutationFn: (input: SearchProfileInput) => (editingProfile ? updateSearchProfile(editingProfile.id, input) : createSearchProfile(input)), + + onError: (error: unknown) => { + toast.error(error instanceof Error ? error.message : 'Unable to save profile.') + }, + + onSuccess: async () => { + await refreshProfiles() + + toast.success(editingProfile ? 'Search profile updated.' : 'Search profile created.') + + setFormOpen(false) + setEditingProfile(undefined) + } + } + + const saveMutation = useMutation(saveMutationOptions) + + const deleteMutationOptions = { + mutationFn: deleteSearchProfile, + + onError: (error: unknown) => { + toast.error(error instanceof Error ? error.message : 'Unable to delete profile.') + }, + + onSuccess: async () => { + await refreshProfiles() + + toast.success('Search profile deleted. Existing indexes were preserved.') + } + } + + const deleteMutation = useMutation(deleteMutationOptions) + + const syncMutationOptions = { + mutationFn: ({ id, mode }: { id?: string; mode: SearchSyncMode }) => synchronizeSearchProfiles(mode, id), + + onError: (error: unknown) => { + toast.error(error instanceof Error ? error.message : 'Synchronization failed.') + }, + + onSettled: async () => { + setSyncTarget(undefined) + + await refreshProfiles() + }, + + onSuccess: ({ result }: Awaited>) => { + toast.success((result.mode === 'full' ? 'Full rebuild' : 'Normal synchronization') + ' completed: ' + result.indexed + ' indexed, ' + result.deleted + ' deleted.') + } + } + + const syncMutation = useMutation(syncMutationOptions) + const profiles = profilesQuery.data ?? [] + const assignedProfiles = profiles.filter((profile) => profile.sales_channel_ids.length > 0) + const salesChannels = salesChannelsQuery.data ?? [] + const salesChannelNames = new Map(salesChannels.map((channel) => [channel.id, channel.name])) + const meilisearchStatusBadge = getMeilisearchStatusBadge({ connected: statusQuery.data?.connected, enabled: statusQuery.data?.enabled, loading: statusQuery.isLoading }) + + const startSync = (mode: SearchSyncMode, id?: string) => { + setSyncTarget(id ?? 'all') + + syncMutation.mutate({ id, mode }) + } + + const requestDelete = async (profile: SearchProfile) => { + const confirmed = await prompt({ title: 'Delete ' + profile.key + '?', description: 'The profile will stop resolving and synchronizing. Existing Meilisearch indexes are deliberately preserved for rollback.', confirmText: 'Delete', cancelText: 'Cancel' }) + + if (confirmed) { + deleteMutation.mutate(profile.id) + } + } + + const openCreate = () => { + setEditingProfile(undefined) + setFormOpen(true) + } + + const openEdit = (profile: SearchProfile) => { + setEditingProfile(profile) + setFormOpen(true) + } + + return ( +
+ +
+
+
+ + + + Meilisearch Configuration + +
+ + + Domain-scoped search profiles are stored in Medusa and applied to storefront search, autocomplete, and synchronization. + +
+ +
+ + { meilisearchStatusBadge.label } + + { + statusQuery.data?.error && ( + + { statusQuery.data.error } + + ) + } + + + + + + +
+
+
+ + +
+ + Search profiles + + + + One profile represents a unique Shop + Domain ID + Language combination and maps it to Medusa Sales Channels. + +
+ +
+ + + + + Profile + + + + Sales Channels + + + + Behavior + + + + Last synchronization + + + + Actions + + + + + + { + profilesQuery.isLoading && ( + + + Loading profiles… + + + + + + + + ) + } + { + profilesQuery.error && ( + + + { profilesQuery.error.message } + + + + + + + + ) + } + { + !profilesQuery.isLoading && !profilesQuery.error && !profiles.length && ( + + + No search profiles configured. + + + + + + + + ) + } + { + profiles.map((profile) => ( + + +
+ + { profile.key } + +
+
+ + +
+ { + profile.sales_channel_ids.length ? profile.sales_channel_ids.map((id) => ( + + { salesChannelNames.get(id) ?? id } + + )) : 'Not assigned' + } +
+
+ + + + + + +
+ + { profile.last_sync_status } + + + + { formatDate(profile.last_synced_at) } + + { + profile.last_sync_error && ( + + { profile.last_sync_error } + + ) + } +
+
+ + +
+ + + + + openEdit(profile) + } + > + + + + requestDelete(profile) + } + > + + +
+
+
+ )) + } +
+
+
+
+ + + + + + { + setFormOpen(open) + + if (!open) { + setEditingProfile(undefined) + } + } + } + + onSubmit = { + (input) => saveMutation.mutate(input) + } + /> +
+ ) +} + +export const config = defineRouteConfig({ label: 'Meilisearch' }) + +export default MeilisearchSettingsPage diff --git a/apps/medusa-be/src/api/admin-app-static.ts b/apps/medusa-be/src/api/admin-app-static.ts index 45a2a9a307..fcd7713ee5 100644 --- a/apps/medusa-be/src/api/admin-app-static.ts +++ b/apps/medusa-be/src/api/admin-app-static.ts @@ -6,10 +6,15 @@ import type { MedusaResponse, } from "@medusajs/framework" -const ADMIN_PUBLIC_DIRS = [ - path.join(process.cwd(), ".medusa/server/public/app"), - path.join(process.cwd(), ".medusa/server/public/admin"), +const MEDUSA_PROJECT_DIRS = [ + process.cwd(), + path.join(process.cwd(), "apps/medusa-be"), ] +const ADMIN_PUBLIC_DIRS = MEDUSA_PROJECT_DIRS.flatMap((projectDir) => [ + path.join(projectDir, ".medusa/server/public/app"), + path.join(projectDir, ".medusa/server/public/admin"), + path.join(projectDir, ".medusa/admin"), +]) const APP_PATH_PREFIX_REGEX = /^\/app\/?/ const getAdminPublicDir = () => @@ -26,6 +31,13 @@ const isPathInsideDirectory = (baseDir: string, filePath: string) => { } const getRequestPath = (req: MedusaRequest) => { + if ( + "originalUrl" in req && + typeof req.originalUrl === "string" + ) { + return req.originalUrl.split("?")[0] ?? "/app" + } + if ("path" in req && typeof req.path === "string") { return req.path } diff --git a/apps/medusa-be/src/api/admin/search-profiles/[id]/route.ts b/apps/medusa-be/src/api/admin/search-profiles/[id]/route.ts new file mode 100644 index 0000000000..5b38fbcafe --- /dev/null +++ b/apps/medusa-be/src/api/admin/search-profiles/[id]/route.ts @@ -0,0 +1,43 @@ +import type { MedusaRequest, MedusaResponse } from '@medusajs/framework/http' +import type { Query } from '@medusajs/framework/types' +import { ContainerRegistrationKeys } from '@medusajs/framework/utils' +import { getSearchProfileService, retrieveSearchProfileOrThrow, toSearchProfileResponse, toSearchProfileWriteInput, updateStoredSearchProfile, validateProfileChange, validateSalesChannelIds } from '../utils' +import type { AdminSearchProfileInputSchemaType } from '../validators' + +export async function GET(request: MedusaRequest, response: MedusaResponse) { + const service = getSearchProfileService(request.scope) + const profile = await retrieveSearchProfileOrThrow(service, request.params.id ?? '') + + response.json({ profile: toSearchProfileResponse(profile) }) +} + +export async function POST(request: MedusaRequest, response: MedusaResponse) { + const service = getSearchProfileService(request.scope) + const id = request.params.id ?? '' + + await retrieveSearchProfileOrThrow(service, id) + + const query = request.scope.resolve(ContainerRegistrationKeys.QUERY) + const input = toSearchProfileWriteInput(request.validatedBody) + + await validateSalesChannelIds(query, input.sales_channel_ids) + await validateProfileChange({ currentId: id, input: input, service: service }) + + const updated = await updateStoredSearchProfile(service, id, input) + + await service.invalidateRuntimeProfileCache() + + response.json({ profile: toSearchProfileResponse(updated) }) +} + +export async function DELETE(request: MedusaRequest, response: MedusaResponse) { + const service = getSearchProfileService(request.scope) + const id = request.params.id ?? '' + + await retrieveSearchProfileOrThrow(service, id) + + await service.deleteSearchProfiles(id) + await service.invalidateRuntimeProfileCache() + + response.json({ deleted: true, id: id, object: 'search_profile' }) +} diff --git a/apps/medusa-be/src/api/admin/search-profiles/[id]/sync/route.ts b/apps/medusa-be/src/api/admin/search-profiles/[id]/sync/route.ts new file mode 100644 index 0000000000..54b786a17a --- /dev/null +++ b/apps/medusa-be/src/api/admin/search-profiles/[id]/sync/route.ts @@ -0,0 +1,18 @@ +import type { MedusaRequest, MedusaResponse } from '@medusajs/framework/http' +import { MedusaError } from '@medusajs/framework/utils' +import { synchronizeSearchProfiles } from '../../../../../modules/meilisearch/synchronize' +import { getSearchProfileService, retrieveSearchProfileOrThrow } from '../../utils' +import type { AdminSearchProfileSyncSchemaType } from '../../validators' + +export async function POST(request: MedusaRequest, response: MedusaResponse) { + const service = getSearchProfileService(request.scope) + const profile = await retrieveSearchProfileOrThrow(service, request.params.id ?? '') + + if (!Array.isArray(profile.sales_channel_ids) || profile.sales_channel_ids.length === 0) { + throw new MedusaError(MedusaError.Types.NOT_ALLOWED, 'Assign at least one Sales Channel before synchronizing this profile.') + } + + const result = await synchronizeSearchProfiles(request.scope, request.validatedBody.mode, { profileKeys: [profile.key] }) + + response.json({ result }) +} diff --git a/apps/medusa-be/src/api/admin/search-profiles/[id]/test/route.ts b/apps/medusa-be/src/api/admin/search-profiles/[id]/test/route.ts new file mode 100644 index 0000000000..3e0f39f80c --- /dev/null +++ b/apps/medusa-be/src/api/admin/search-profiles/[id]/test/route.ts @@ -0,0 +1,30 @@ +import type { MedusaRequest, MedusaResponse } from '@medusajs/framework/http' +import type { MeiliSearchService } from '@rokmohar/medusa-plugin-meilisearch' +import { cleanSearchText } from '../../../../../modules/meilisearch/documents' +import { persistedSearchProfileToRuntime } from '../../../../../modules/meilisearch/profiles' +import { buildProductResultFilter, isAcceptedProductHit } from '../../../../../modules/meilisearch/search-results' +import { MEILISEARCH } from '../../../../../workflows/meilisearch' +import { getSearchProfileService, retrieveSearchProfileOrThrow } from '../../utils' +import type { AdminSearchProfileTestSchemaType } from '../../validators' + +export async function POST(request: MedusaRequest, response: MedusaResponse) { + const service = getSearchProfileService(request.scope) + const persisted = await retrieveSearchProfileOrThrow(service, request.params.id ?? '') + const profile = persistedSearchProfileToRuntime(persisted) + const { limit, type } = request.validatedBody + const query = cleanSearchText(request.validatedBody.query) + const meilisearch = request.scope.resolve(MEILISEARCH) + const result = await meilisearch.search(profile.indexes[type], query, { paginationOptions: { limit, offset: 0 }, filter: type === 'product' ? buildProductResultFilter(profile.separateVariantResults, query) : undefined, additionalOptions: { showRankingScore: true, ...(type === 'product' && !query ? { sort: ['facet_popularity:desc'] } : {}) } }) + const hits = Array.isArray(result.hits) ? result.hits : [] + const acceptedHits = type === 'product' && query ? hits.filter((hit) => isAcceptedProductHit(hit, query, profile.minimumRankingScore, profile.strict)) : hits + + response.json({ + profile: profile.key, + type: type, + query: query, + minimum_ranking_score: type === 'product' ? profile.minimumRankingScore : null, + hits: acceptedHits, + raw_hit_count: hits.length, + processing_time_ms: (result as unknown as { processingTimeMs?: number }).processingTimeMs ?? null + }) +} diff --git a/apps/medusa-be/src/api/admin/search-profiles/middlewares.ts b/apps/medusa-be/src/api/admin/search-profiles/middlewares.ts new file mode 100644 index 0000000000..5fe44a253c --- /dev/null +++ b/apps/medusa-be/src/api/admin/search-profiles/middlewares.ts @@ -0,0 +1,31 @@ +import { validateAndTransformBody } from '@medusajs/framework' +import type { MiddlewareRoute } from '@medusajs/framework/http' +import { AdminSearchProfileInputSchema, AdminSearchProfileSyncSchema, AdminSearchProfileTestSchema } from './validators' + +export const adminSearchProfileRoutesMiddlewares: MiddlewareRoute[] = [ + { + methods: ['POST'], + matcher: '/admin/search-profiles', + middlewares: [validateAndTransformBody(AdminSearchProfileInputSchema)] + }, + { + methods: ['POST'], + matcher: '/admin/search-profiles/sync', + middlewares: [validateAndTransformBody(AdminSearchProfileSyncSchema)] + }, + { + methods: ['POST'], + matcher: '/admin/search-profiles/:id', + middlewares: [validateAndTransformBody(AdminSearchProfileInputSchema)] + }, + { + methods: ['POST'], + matcher: '/admin/search-profiles/:id/sync', + middlewares: [validateAndTransformBody(AdminSearchProfileSyncSchema)] + }, + { + methods: ['POST'], + matcher: '/admin/search-profiles/:id/test', + middlewares: [validateAndTransformBody(AdminSearchProfileTestSchema)] + } +] diff --git a/apps/medusa-be/src/api/admin/search-profiles/route.ts b/apps/medusa-be/src/api/admin/search-profiles/route.ts new file mode 100644 index 0000000000..fc55fc76c8 --- /dev/null +++ b/apps/medusa-be/src/api/admin/search-profiles/route.ts @@ -0,0 +1,28 @@ +import type { MedusaRequest, MedusaResponse } from '@medusajs/framework/http' +import type { Query } from '@medusajs/framework/types' +import { ContainerRegistrationKeys } from '@medusajs/framework/utils' +import type { SearchProfileDTO } from '../../../modules/search-profile' +import { getSearchProfileService, toSearchProfileResponse, toSearchProfileWriteInput, validateProfileChange, validateSalesChannelIds } from './utils' +import type { AdminSearchProfileInputSchemaType } from './validators' + +export async function GET(request: MedusaRequest, response: MedusaResponse) { + const service = getSearchProfileService(request.scope) + const profiles = await service.listConfiguredProfiles() + + response.json({ profiles: profiles.map(toSearchProfileResponse) }) +} + +export async function POST(request: MedusaRequest, response: MedusaResponse) { + const service = getSearchProfileService(request.scope) + const query = request.scope.resolve(ContainerRegistrationKeys.QUERY) + const input = toSearchProfileWriteInput(request.validatedBody) + + await validateSalesChannelIds(query, input.sales_channel_ids) + await validateProfileChange({ input, service }) + + const created = (await service.createSearchProfiles(input)) as unknown as SearchProfileDTO + + await service.invalidateRuntimeProfileCache() + + response.status(201).json({ profile: toSearchProfileResponse(created) }) +} diff --git a/apps/medusa-be/src/api/admin/search-profiles/status/route.ts b/apps/medusa-be/src/api/admin/search-profiles/status/route.ts new file mode 100644 index 0000000000..000ec0fc4c --- /dev/null +++ b/apps/medusa-be/src/api/admin/search-profiles/status/route.ts @@ -0,0 +1,19 @@ +import type { MedusaRequest, MedusaResponse } from '@medusajs/framework/http' +import { MeilisearchAdminClient } from '../../../../modules/meilisearch/admin-client' +import { isMeilisearchEnabled } from '../../../../modules/meilisearch/env' + +export async function GET(_request: MedusaRequest, response: MedusaResponse) { + if (!isMeilisearchEnabled()) { + response.json({ enabled: false, connected: false, status: 'disabled' }) + + return + } + + try { + const health = await new MeilisearchAdminClient().health() + + response.json({ enabled: true, connected: health.status === 'available', status: health.status ?? 'unknown' }) + } catch (error) { + response.json({ enabled: true, connected: false, status: 'unavailable', error: error instanceof Error ? error.message : String(error) }) + } +} diff --git a/apps/medusa-be/src/api/admin/search-profiles/sync/route.ts b/apps/medusa-be/src/api/admin/search-profiles/sync/route.ts new file mode 100644 index 0000000000..45ee3019c7 --- /dev/null +++ b/apps/medusa-be/src/api/admin/search-profiles/sync/route.ts @@ -0,0 +1,9 @@ +import type { MedusaRequest, MedusaResponse } from '@medusajs/framework/http' +import { synchronizeSearchProfiles } from '../../../../modules/meilisearch/synchronize' +import type { AdminSearchProfileSyncSchemaType } from '../validators' + +export async function POST(request: MedusaRequest, response: MedusaResponse) { + const result = await synchronizeSearchProfiles(request.scope, request.validatedBody.mode) + + response.json({ result }) +} diff --git a/apps/medusa-be/src/api/admin/search-profiles/utils.ts b/apps/medusa-be/src/api/admin/search-profiles/utils.ts new file mode 100644 index 0000000000..53cf71a304 --- /dev/null +++ b/apps/medusa-be/src/api/admin/search-profiles/utils.ts @@ -0,0 +1,117 @@ +import type { MedusaContainer, Query } from '@medusajs/framework/types' +import { MedusaError } from '@medusajs/framework/utils' +import { parseSearchProfileConfiguration, persistedSearchProfileToRuntime, type SearchProfile, SearchProfileConfigurationError, validateSearchProfileSet } from '../../../modules/meilisearch/profiles' +import { SEARCH_PROFILE_MODULE, type SearchProfileDTO, type SearchProfileModuleService, type SearchProfileWriteInput } from '../../../modules/search-profile' +import type { AdminSearchProfileInputSchemaType } from './validators' + +type MutableSearchProfileService = SearchProfileModuleService & { + createSearchProfiles: (data: Record) => Promise + deleteSearchProfiles: (ids: string | string[]) => Promise + retrieveSearchProfile: (id: string) => Promise + updateSearchProfiles: (data: Record | Record[]) => Promise + invalidateRuntimeProfileCache: () => Promise +} + +export const getSearchProfileService = (container: MedusaContainer): MutableSearchProfileService => container.resolve(SEARCH_PROFILE_MODULE) + +export const toSearchProfileWriteInput = (input: AdminSearchProfileInputSchemaType): SearchProfileWriteInput => ({ + ...input, + sales_channel_ids: [...new Set(input.sales_channel_ids)] +}) + +const writeInputToRuntime = (input: SearchProfileWriteInput, id?: string): SearchProfile => ({ + ...parseSearchProfileConfiguration({ + availability: input.availability, + domain: input.domain, + key: input.key, + + limits: { + autocomplete: { + product: input.autocomplete_product_limit, + category: input.autocomplete_category_limit, + brand: input.autocomplete_brand_limit, + content: input.autocomplete_content_limit + }, + + fullSearch: input.full_search_limit, + page: input.max_results_per_page, + popular: input.popular_limit + }, + + locale: input.locale, + minimumRankingScore: input.minimum_ranking_score ?? undefined, + salesChannelIds: input.sales_channel_ids, + separateVariantResults: input.separate_variant_results, + shop: input.shop, + strict: input.strict + }), + + id +}) + +export const validateSalesChannelIds = async (query: Query, ids: string[]) => { + const uniqueIds = [...new Set(ids)] + + if (!uniqueIds.length) { + return + } + + const { data } = await query.graph({ entity: 'sales_channel', fields: ['id'], filters: { id: { $in: uniqueIds } } }) + const found = new Set((data as { id?: string }[]).map((entry) => entry.id).filter((id): id is string => Boolean(id))) + const missing = uniqueIds.filter((id) => !found.has(id)) + + if (missing.length) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, 'Unknown Sales Channel IDs: ' + missing.join(', ')) + } +} + +export const validateProfileChange = async (options: { currentId?: string; input: SearchProfileWriteInput; service: SearchProfileModuleService }) => { + const records = await options.service.listConfiguredProfiles() + + const candidates = records + .filter((profile) => profile.id !== options.currentId) + .map((profile) => persistedSearchProfileToRuntime(profile)) + + const candidate = writeInputToRuntime(options.input, options.currentId) + + const enabledProfiles = [ + ...candidates.filter((profile) => profile.salesChannelIds.length > 0), + ...(options.input.sales_channel_ids.length > 0 ? [candidate] : []) + ] + + try { + validateSearchProfileSet(enabledProfiles) + } catch (error) { + if (error instanceof SearchProfileConfigurationError) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.message) + } + + throw error + } +} + +export const toSearchProfileResponse = (profile: SearchProfileDTO) => { + const runtime = persistedSearchProfileToRuntime(profile) + const salesChannelIds = Array.isArray(profile.sales_channel_ids) ? profile.sales_channel_ids : [] + + return { ...profile, sales_channel_ids: salesChannelIds, effective_minimum_ranking_score: runtime.minimumRankingScore } +} + +export const retrieveSearchProfileOrThrow = async (service: SearchProfileModuleService, id: string): Promise => { + try { + return (await service.retrieveSearchProfile(id)) as unknown as SearchProfileDTO + } catch { + throw new MedusaError(MedusaError.Types.NOT_FOUND, 'Search profile ' + id + ' was not found.') + } +} + +export const updateStoredSearchProfile = async (service: MutableSearchProfileService, id: string, input: SearchProfileWriteInput) => { + const result = await service.updateSearchProfiles({ id: id, ...input }) + const updated = Array.isArray(result) ? result.find((profile) => profile.id === id) : result + + if (!updated) { + throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, 'Search profile update returned no result.') + } + + return updated +} diff --git a/apps/medusa-be/src/api/admin/search-profiles/validators.ts b/apps/medusa-be/src/api/admin/search-profiles/validators.ts new file mode 100644 index 0000000000..78bcc9a6da --- /dev/null +++ b/apps/medusa-be/src/api/admin/search-profiles/validators.ts @@ -0,0 +1,43 @@ +import { z as zod } from '@medusajs/framework/zod' + +const INDEX_SAFE_VALUE = /^[a-z0-9][a-z0-9_-]*$/ +const indexSafeString = zod.string().trim().min(1).max(255).regex(INDEX_SAFE_VALUE, 'Use lowercase letters, numbers, underscores, and hyphens only.') + +export const AdminSearchProfileInputSchema = zod + .object({ + key: indexSafeString, + shop: indexSafeString, + domain: indexSafeString, + locale: indexSafeString, + sales_channel_ids: zod.array(zod.string().trim().min(1)).max(50), + strict: zod.boolean(), + separate_variant_results: zod.boolean(), + minimum_ranking_score: zod.number().finite().min(0).max(1).nullable(), + availability: zod.enum(['all', 'in-stock']), + autocomplete_product_limit: zod.number().int().min(1).max(24), + autocomplete_category_limit: zod.number().int().min(1).max(24), + autocomplete_brand_limit: zod.number().int().min(1).max(24), + autocomplete_content_limit: zod.number().int().min(1).max(24), + full_search_limit: zod.number().int().min(1).max(1000), + max_results_per_page: zod.number().int().min(1).max(100), + popular_limit: zod.number().int().min(1).max(48) + }) + .strict() + +export const AdminSearchProfileSyncSchema = zod + .object({ + mode: zod.enum(['normal', 'full']) + }) + .strict() + +export const AdminSearchProfileTestSchema = zod + .object({ + query: zod.string().trim().max(250), + type: zod.enum(['product', 'category', 'brand', 'content']), + limit: zod.number().int().min(1).max(25).optional().default(10) + }) + .strict() + +export type AdminSearchProfileInputSchemaType = zod.infer +export type AdminSearchProfileSyncSchemaType = zod.infer +export type AdminSearchProfileTestSchemaType = zod.infer diff --git a/apps/medusa-be/src/api/hooks/cms/invalidate/route.ts b/apps/medusa-be/src/api/hooks/cms/invalidate/route.ts index ef9968f9ec..7784195073 100644 --- a/apps/medusa-be/src/api/hooks/cms/invalidate/route.ts +++ b/apps/medusa-be/src/api/hooks/cms/invalidate/route.ts @@ -2,6 +2,10 @@ import { createHmac } from "node:crypto" import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" import type { Logger } from "@medusajs/framework/types" import { ContainerRegistrationKeys } from "@medusajs/framework/utils" +import { + type CmsSearchChange, + reconcileContentSearchChange, +} from "../../../../modules/meilisearch/content-events" import { PAYLOAD_MODULE } from "../../../../modules/payload" import type PayloadModuleService from "../../../../modules/payload/service" import { @@ -12,7 +16,12 @@ import { /** Expected webhook payload from Payload CMS invalidation hook. */ type PayloadWebhookBody = { collection?: string - doc?: { id?: string; slug?: string; locale?: string } + doc?: Record & { + id?: string + slug?: string + locale?: string + } + operation?: string } /** Hook endpoint to invalidate cached CMS content in Medusa. */ @@ -50,6 +59,11 @@ export async function POST(req: MedusaRequest, res: MedusaResponse) { body.doc?.slug, body.doc?.locale ) + await reconcileContentSearchChange( + body as CmsSearchChange, + logger, + req.scope + ) } catch (error) { logger.error( `CMS cache invalidation failed (collection="${body.collection}", slug="${body.doc?.slug ?? "n/a"}", locale="${body.doc?.locale ?? "n/a"}")`, diff --git a/apps/medusa-be/src/api/middlewares.ts b/apps/medusa-be/src/api/middlewares.ts index 8df35e209a..fbc6eda3a6 100644 --- a/apps/medusa-be/src/api/middlewares.ts +++ b/apps/medusa-be/src/api/middlewares.ts @@ -27,6 +27,7 @@ import { adminPromotionsExtensionMiddlewares } from "./admin/promotions/middlewa import { adminPublishableKeyRoutesMiddlewares } from "./admin/provisioning/publishable-key/middlewares" import { adminQrPaymentConfigRoutesMiddlewares } from "./admin/qr-payment-config/middlewares" import { adminReviewRoutesMiddlewares } from "./admin/reviews/middlewares" +import { adminSearchProfileRoutesMiddlewares } from "./admin/search-profiles/middlewares" import { adminStorefrontTextRoutesMiddlewares } from "./admin/storefront-texts/middlewares" import { serveAdminAppStatic } from "./admin-app-static" import { storeBrandsRoutesMiddlewares } from "./store/brands/middlewares" @@ -38,6 +39,7 @@ import { storeProductListsRoutesMiddlewares } from "./store/product-lists/middle import { storeProductLocationAvailabilityRoutesMiddlewares } from "./store/products/[id]/location-availability/middlewares" import { storeProductAttributesRoutesMiddlewares } from "./store/products/[id]/product-attributes/middlewares" import { storeReviewRoutesMiddlewares } from "./store/reviews/middlewares" +import { storeSearchAutocompleteRoutesMiddlewares } from "./store/search/autocomplete/middlewares" import { storeShopReviewRoutesMiddlewares } from "./store/shop-reviews/middlewares" import { storeStorefrontTextRoutesMiddlewares } from "./store/storefront-texts/middlewares" @@ -86,10 +88,12 @@ export default defineMiddlewares({ ...adminProductAttributeRoutesMiddlewares, ...adminQrPaymentConfigRoutesMiddlewares, ...adminReviewRoutesMiddlewares, + ...adminSearchProfileRoutesMiddlewares, ...adminStorefrontTextRoutesMiddlewares, ...storeMiddlewares, ...storeCustomerReviewRoutesMiddlewares, ...storeCatalogProductsRoutesMiddlewares, + ...storeSearchAutocompleteRoutesMiddlewares, ...storeCmsRoutesMiddlewares, ...storeProductListsRoutesMiddlewares, ...storeProductLocationAvailabilityRoutesMiddlewares, diff --git a/apps/medusa-be/src/api/store/catalog/products/route.ts b/apps/medusa-be/src/api/store/catalog/products/route.ts index 8947e5d752..a8abc99a71 100644 --- a/apps/medusa-be/src/api/store/catalog/products/route.ts +++ b/apps/medusa-be/src/api/store/catalog/products/route.ts @@ -1,5 +1,6 @@ import type { Query } from "@medusajs/framework" import type { MedusaResponse } from "@medusajs/framework/http" +import type { Logger } from "@medusajs/framework/types" import { ContainerRegistrationKeys, ProductStatus, @@ -10,6 +11,7 @@ import { wrapProductsWithTaxPrices, } from "@medusajs/medusa/api/store/products/helpers" import type { MeiliSearchService } from "@rokmohar/medusa-plugin-meilisearch" +import { cleanSearchText } from "../../../../modules/meilisearch/documents" import { isMeilisearchEnabled } from "../../../../modules/meilisearch/env" import { extractBrandHandleFromFacetId, @@ -19,6 +21,19 @@ import { STATUS_FACET_DEFINITIONS, STATUS_FACET_LABEL_BY_ID, } from "../../../../modules/meilisearch/facets/product-facets" +import { + loadSearchProfiles, + resolveSearchProfile, + type SearchProfile, + SearchProfileResolutionError, +} from "../../../../modules/meilisearch/profiles" +import { + buildProductResultFilter, + expandProductsBySearchMatches, + getSalesChannelIds, + type RankedProductMatch, + selectRankedProductIds, +} from "../../../../modules/meilisearch/search-results" import { decorateProductsWithMeasurements, getMeasurementDecorationOptions, @@ -29,7 +44,9 @@ import { buildCatalogFilterExpressions, type FacetCountItem, getFacetDistribution, + getFacetDistributionFromHits, getNumericFacetStats, + getNumericFacetStatsFromHits, humanizeFacetHandle, normalizeBrandParam, normalizeCategoryIdsParam, @@ -45,10 +62,6 @@ import { type StoreCatalogProductsSchemaType, } from "./validators" -type MeiliProductHit = { - id?: string | number -} - type BrandRecord = { handle?: string title?: string @@ -59,6 +72,19 @@ type CategoryRecord = { name?: string } +type ProductWithCalculatedPrices = { + id?: unknown + search_result?: { + variant_id?: unknown + } + variants?: Array<{ + id?: unknown + calculated_price?: { + calculated_amount?: unknown + } | null + }> | null +} + const FACETS_TO_FETCH = [ "facet_status", "facet_form", @@ -121,44 +147,9 @@ const mapFormFacets = (facetCounts: Map): FacetCountItem[] => { return [...result, ...additionalItems] } -const getProductIdFromHit = (hit: unknown): string | undefined => { - if (!hit || typeof hit !== "object" || Array.isArray(hit)) { - return - } - - const id = (hit as MeiliProductHit).id - if (typeof id === "string") { - return id - } - if (Number.isFinite(id)) { - return String(id) - } - - return -} - const escapeMeiliFilterValue = (value: string): string => value.replaceAll("\\", "\\\\").replaceAll('"', '\\"') -const getSalesChannelIds = (value: unknown): string[] => { - if (Array.isArray(value)) { - return value.filter((item): item is string => typeof item === "string") - } - - if (typeof value === "string") { - return [value] - } - - if (value && typeof value === "object" && !Array.isArray(value)) { - const inValue = (value as Record).$in - if (Array.isArray(inValue)) { - return inValue.filter((item): item is string => typeof item === "string") - } - } - - return [] -} - const buildMeiliOrExpression = ( field: string, values: string[] @@ -307,6 +298,79 @@ const mapDynamicFacets = ( })) ) +const getLowestCalculatedProductPrice = ( + product: ProductWithCalculatedPrices +): number | undefined => { + const selectedVariantId = product.search_result?.variant_id + const selectedVariant = (product.variants ?? []).find( + (variant) => + typeof selectedVariantId === "string" && + variant.id === selectedVariantId + ) + const selectedVariantPrice = + selectedVariant?.calculated_price?.calculated_amount + + if ( + typeof selectedVariantPrice === "number" && + Number.isFinite(selectedVariantPrice) + ) { + return selectedVariantPrice + } + + const prices = (product.variants ?? []) + .map((variant) => variant.calculated_price?.calculated_amount) + .filter( + (amount): amount is number => + typeof amount === "number" && Number.isFinite(amount) + ) + + return prices.length > 0 ? Math.min(...prices) : undefined +} + +const resolveAuthoritativePriceSortDirection = ( + sort: string +): 1 | -1 | undefined => { + if (sort === "price-asc") { + return 1 + } + if (sort === "price-desc") { + return -1 + } + return +} + +const selectProductMatchesForHydration = (options: { + cleanedQuery: string + limit: number + matchingProducts: RankedProductMatch[] + offset: number + priceSortDirection?: 1 | -1 +}): RankedProductMatch[] => { + if (options.priceSortDirection) { + return options.matchingProducts + } + if (options.cleanedQuery) { + return options.matchingProducts.slice( + options.offset, + options.offset + options.limit + ) + } + return options.matchingProducts +} + +const resolveResultCount = (options: { + estimatedTotalHits?: number + exhaustiveCandidateSearch: boolean + fallbackCount: number + matchingCount: number +}): number => { + if (options.exhaustiveCandidateSearch) { + return options.matchingCount + } + return options.estimatedTotalHits ?? options.fallbackCount +} + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: This route coordinates search, authoritative hydration, facets, pricing, and degraded fallback. export async function GET( req: RequestWithContext, res: MedusaResponse @@ -327,8 +391,29 @@ export async function GET( const meilisearchService = req.scope.resolve(MEILISEARCH) const page = validatedQuery.page - const limit = validatedQuery.limit + const salesChannelIds = getSalesChannelIds( + req.filterableFields.sales_channel_id + ) + let searchProfile: SearchProfile + try { + searchProfile = resolveSearchProfile( + { + locale: validatedQuery.locale, + requestedKey: validatedQuery.profile, + salesChannelIds, + }, + await loadSearchProfiles(req.scope) + ) + } catch (error) { + if (error instanceof SearchProfileResolutionError) { + res.status(400).json({ message: error.message }) + return + } + throw error + } + const limit = Math.min(validatedQuery.limit, searchProfile.limits.page) const offset = (page - 1) * limit + const cleanedQuery = cleanSearchText(validatedQuery.q) const categoryIds = normalizeCategoryIdsParam(validatedQuery.category_id) const statusIds = normalizeStatusParam(validatedQuery.status) @@ -346,35 +431,154 @@ export async function GET( priceMax: validatedQuery.price_max, }) - const sort = resolveCatalogSort(validatedQuery.sort) + const authoritativePriceSortDirection = + resolveAuthoritativePriceSortDirection(validatedQuery.sort) + const exhaustiveCandidateSearch = Boolean( + cleanedQuery || authoritativePriceSortDirection + ) + const sort = + resolveCatalogSort(validatedQuery.sort) ?? + (cleanedQuery ? undefined : ["facet_popularity:desc"]) + const meiliSort = authoritativePriceSortDirection ? undefined : sort const searchFilters = [ + buildProductResultFilter( + searchProfile.separateVariantResults, + cleanedQuery + ), ...filterExpressions, ...buildVisibilityFilterExpressions(req.filterableFields.sales_channel_id), ] const searchFilter = searchFilters.length > 0 ? searchFilters.join(" AND ") : undefined - const searchResult = await meilisearchService.search( - "products", - validatedQuery.q.trim(), - { - paginationOptions: { - limit, - offset, + let searchResult: Awaited> + try { + searchResult = await meilisearchService.search( + searchProfile.indexes.product, + cleanedQuery, + { + paginationOptions: { + limit: exhaustiveCandidateSearch + ? searchProfile.limits.fullSearch + : limit, + offset: exhaustiveCandidateSearch ? 0 : offset, + }, + filter: searchFilter, + additionalOptions: { + attributesToRetrieve: [ + "id", + "title", + "brand", + "search_product_id", + "search_variant_id", + "search_variant_title", + "search_variant_titles", + "search_identifiers_normalized", + "facet_status", + "facet_form", + "facet_brand", + "facet_ingredient", + "facet_price", + ], + facets: FACETS_TO_FETCH, + ...(cleanedQuery ? { showRankingScore: true } : {}), + ...(meiliSort ? { sort: meiliSort } : {}), + }, + } + ) + } catch (error) { + const logger = req.scope.resolve(ContainerRegistrationKeys.LOGGER) + logger.warn( + `Meilisearch catalog query failed; using capped Medusa fallback: ${ + error instanceof Error ? error.message : String(error) + }` + ) + const pricingContext = req.pricingContext + ? QueryContext(req.pricingContext) + : undefined + const productFields = pricingContext + ? [ + ...STORE_CATALOG_PRODUCTS_DEFAULT_FIELDS, + ...STORE_CATALOG_PRODUCTS_PRICING_FIELDS, + ] + : STORE_CATALOG_PRODUCTS_DEFAULT_FIELDS + const { data: fallbackProducts, metadata: fallbackMetadata } = await queryService.graph({ + entity: "product", + fields: productFields, + filters: await normalizeProductSalesChannelFilter( + queryService, + remoteQuery, + { + ...(cleanedQuery ? { q: cleanedQuery } : {}), + sales_channel_id: req.filterableFields.sales_channel_id, + status: ProductStatus.PUBLISHED, + } + ), + pagination: { + take: limit, + skip: offset, + }, + context: pricingContext + ? { + variants: { + calculated_price: pricingContext, + }, + } + : undefined, + }) + + await wrapProductsWithTaxPrices( + req, + fallbackProducts as Parameters[1] + ) + await decorateProductsWithMeasurements( + req.scope, + fallbackProducts as Parameters< + typeof decorateProductsWithMeasurements + >[1], + measurementDecorationOptions + ) + res.json({ + products: fallbackProducts, + count: fallbackMetadata?.count ?? fallbackProducts.length, + page, + limit, + totalPages: Math.ceil((fallbackMetadata?.count ?? fallbackProducts.length) / limit), + facets: { + status: mapStatusFacets(new Map()), + form: mapFormFacets(new Map()), + brand: [], + ingredient: [], + price: { + min: null, + max: null, + }, }, - filter: searchFilter, - additionalOptions: { - attributesToRetrieve: ["id"], - facets: FACETS_TO_FETCH, - ...(sort ? { sort } : {}), + search: { + degraded: true, + exactIdentifierMatch: false, + profile: searchProfile.key, }, - } - ) + }) + return + } - const productIds = Array.isArray(searchResult.hits) - ? searchResult.hits - .map((hit) => getProductIdFromHit(hit)) - .filter((id): id is string => Boolean(id)) - : [] + const rankedProducts = selectRankedProductIds( + searchResult.hits, + cleanedQuery, + searchProfile.minimumRankingScore, + searchProfile.strict + ) + const matchingProducts = rankedProducts.matches + const productMatches = selectProductMatchesForHydration({ + cleanedQuery, + limit, + matchingProducts, + offset, + priceSortDirection: authoritativePriceSortDirection, + }) + const productIds = Array.from( + new Set(productMatches.map((match) => match.productId)) + ) const pricingContext = req.pricingContext ? QueryContext(req.pricingContext) : undefined @@ -411,36 +615,62 @@ export async function GET( : undefined, }) - const productOrder = new Map(productIds.map((id, index) => [id, index])) - const orderedProducts = [...products].sort((left, right) => { - const leftId = typeof left.id === "string" ? left.id : "" - const rightId = typeof right.id === "string" ? right.id : "" - const leftIndex = productOrder.get(leftId) ?? Number.MAX_SAFE_INTEGER - const rightIndex = productOrder.get(rightId) ?? Number.MAX_SAFE_INTEGER - return leftIndex - rightIndex - }) - - const statusFacetCounts = getFacetDistribution( - searchResult.facetDistribution, - "facet_status" - ) - const formFacetCounts = getFacetDistribution( - searchResult.facetDistribution, - "facet_form" - ) - const brandFacetCounts = getFacetDistribution( - searchResult.facetDistribution, - "facet_brand" + const orderedProducts = expandProductsBySearchMatches( + products as Record[], + productMatches ) - const ingredientFacetCounts = getFacetDistribution( - searchResult.facetDistribution, - "facet_ingredient" - ) - const priceFacetStats = getNumericFacetStats( - searchResult.facetStats, - "facet_price" + await wrapProductsWithTaxPrices( + req, + orderedProducts as unknown as Parameters[1] ) + const finalProducts = authoritativePriceSortDirection + ? [...orderedProducts] + .sort((left, right) => { + const leftPrice = getLowestCalculatedProductPrice( + left as ProductWithCalculatedPrices + ) + const rightPrice = getLowestCalculatedProductPrice( + right as ProductWithCalculatedPrices + ) + + if (leftPrice === undefined && rightPrice === undefined) { + return 0 + } + if (leftPrice === undefined) { + return 1 + } + if (rightPrice === undefined) { + return -1 + } + + return (leftPrice - rightPrice) * authoritativePriceSortDirection + }) + .slice(offset, offset + limit) + : orderedProducts + + const statusFacetCounts = cleanedQuery + ? getFacetDistributionFromHits(rankedProducts.selectedHits, "facet_status") + : getFacetDistribution(searchResult.facetDistribution, "facet_status") + const formFacetCounts = cleanedQuery + ? getFacetDistributionFromHits(rankedProducts.selectedHits, "facet_form") + : getFacetDistribution(searchResult.facetDistribution, "facet_form") + const brandFacetCounts = cleanedQuery + ? getFacetDistributionFromHits(rankedProducts.selectedHits, "facet_brand") + : getFacetDistribution(searchResult.facetDistribution, "facet_brand") + const ingredientFacetCounts = cleanedQuery + ? getFacetDistributionFromHits( + rankedProducts.selectedHits, + "facet_ingredient" + ) + : getFacetDistribution(searchResult.facetDistribution, "facet_ingredient") + const priceFacetStats = cleanedQuery + ? getNumericFacetStatsFromHits( + rankedProducts.selectedHits, + "facet_price" + ) + : getNumericFacetStats(searchResult.facetStats, "facet_price") + const [brandLabelsById, ingredientLabelsById] = await Promise.all([ resolveBrandFacetLabels(queryService, Array.from(brandFacetCounts.keys())), resolveIngredientFacetLabels( @@ -449,27 +679,30 @@ export async function GET( ), ]) - const count = - typeof searchResult.estimatedTotalHits === "number" - ? searchResult.estimatedTotalHits - : orderedProducts.length + const count = resolveResultCount({ + estimatedTotalHits: searchResult.estimatedTotalHits, + exhaustiveCandidateSearch, + fallbackCount: finalProducts.length, + matchingCount: matchingProducts.length, + }) const totalPages = count > 0 ? Math.ceil(count / limit) : 0 - await wrapProductsWithTaxPrices( - req, - orderedProducts as Parameters[1] - ) await decorateProductsWithMeasurements( req.scope, - orderedProducts as Parameters[1], + finalProducts as Parameters[1], measurementDecorationOptions ) res.json({ - products: orderedProducts, + products: finalProducts, count, page, limit, totalPages, + search: { + degraded: false, + exactIdentifierMatch: rankedProducts.exactIdentifierMatch, + profile: searchProfile.key, + }, facets: { status: mapStatusFacets(statusFacetCounts), form: mapFormFacets(formFacetCounts), diff --git a/apps/medusa-be/src/api/store/catalog/products/utils.ts b/apps/medusa-be/src/api/store/catalog/products/utils.ts index 927e80fb39..4da60d717b 100644 --- a/apps/medusa-be/src/api/store/catalog/products/utils.ts +++ b/apps/medusa-be/src/api/store/catalog/products/utils.ts @@ -152,7 +152,7 @@ export const resolveCatalogSort = ( ): string[] | undefined => { switch (sort) { case "best-selling": - return + return ["facet_popularity:desc"] case "newest": return ["created_at:desc"] case "oldest": @@ -256,6 +256,36 @@ export const getFacetDistribution = ( return result } +export const getFacetDistributionFromHits = ( + hits: unknown, + facetKey: string +): Map => { + if (!Array.isArray(hits)) { + return new Map() + } + + const result = new Map() + for (const hit of hits) { + if (!hit || typeof hit !== "object" || Array.isArray(hit)) { + continue + } + + const rawFacet = (hit as Record)[facetKey] + const facetValues = Array.isArray(rawFacet) ? rawFacet : [rawFacet] + const uniqueValues = new Set( + facetValues.filter( + (value): value is string => typeof value === "string" && Boolean(value) + ) + ) + + for (const value of uniqueValues) { + result.set(value, (result.get(value) ?? 0) + 1) + } + } + + return result +} + export const getNumericFacetStats = ( facetStats: unknown, facetKey: string @@ -283,6 +313,30 @@ export const getNumericFacetStats = ( return { min, max } } +export const getNumericFacetStatsFromHits = ( + hits: unknown, + facetKey: string +): { min?: number; max?: number } => { + if (!Array.isArray(hits)) { + return {} + } + + const values = hits + .map((hit) => + hit && typeof hit === "object" && !Array.isArray(hit) + ? (hit as Record)[facetKey] + : undefined + ) + .filter( + (value): value is number => + typeof value === "number" && Number.isFinite(value) + ) + + return values.length > 0 + ? { min: Math.min(...values), max: Math.max(...values) } + : {} +} + export const humanizeFacetHandle = (handle: string): string => handle.replaceAll("-", " ").replaceAll(/\s+/g, " ").trim() diff --git a/apps/medusa-be/src/api/store/catalog/products/validators.ts b/apps/medusa-be/src/api/store/catalog/products/validators.ts index 35c59f6d39..bb5fd56889 100644 --- a/apps/medusa-be/src/api/store/catalog/products/validators.ts +++ b/apps/medusa-be/src/api/store/catalog/products/validators.ts @@ -11,6 +11,11 @@ export const STORE_CATALOG_PRODUCTS_DEFAULT_FIELDS = [ "thumbnail", "metadata", "variants.id", + "variants.title", + "variants.sku", + "variants.ean", + "variants.upc", + "variants.barcode", "categories.id", "categories.name", "categories.handle", @@ -44,6 +49,8 @@ export const StoreCatalogProductsSchema = z .object({ fields: z.string().optional(), q: z.string().optional().default(""), + profile: z.string().trim().min(1).max(120).optional(), + locale: z.string().trim().min(2).max(20).optional(), page: z.coerce.number().int().min(1).optional().default(1), limit: z.coerce.number().int().min(1).max(48).optional().default(12), sort: z.enum(CATALOG_SORT_VALUES).optional().default("recommended"), diff --git a/apps/medusa-be/src/api/store/search/autocomplete/middlewares.ts b/apps/medusa-be/src/api/store/search/autocomplete/middlewares.ts new file mode 100644 index 0000000000..29a898694d --- /dev/null +++ b/apps/medusa-be/src/api/store/search/autocomplete/middlewares.ts @@ -0,0 +1,26 @@ +import { validateAndTransformQuery } from '@medusajs/framework' +import { applyDefaultFilters, authenticate, type MiddlewareRoute } from '@medusajs/framework/http' +import { ProductStatus } from '@medusajs/framework/utils' +import { filterByValidSalesChannels } from '@medusajs/medusa/api/utils/middlewares/products/filter-by-valid-sales-channels' +import { normalizeDataForContext } from '@medusajs/medusa/api/utils/middlewares/products/normalize-data-for-context' +import { setPricingContext } from '@medusajs/medusa/api/utils/middlewares/products/set-pricing-context' +import { setTaxContext } from '@medusajs/medusa/api/utils/middlewares/products/set-tax-context' +import { STORE_CATALOG_PRODUCTS_ALLOWED_FIELDS, STORE_CATALOG_PRODUCTS_DEFAULT_FIELDS } from '../../catalog/products/validators' +import { StoreSearchAutocompleteSchema } from './validators' + +export const storeSearchAutocompleteRoutesMiddlewares: MiddlewareRoute[] = [ + { + methods: ['GET'], + matcher: '/store/search/autocomplete', + + middlewares: [ + authenticate('customer', ['session', 'bearer'], { allowUnauthenticated: true }), + validateAndTransformQuery(StoreSearchAutocompleteSchema, { defaults: STORE_CATALOG_PRODUCTS_DEFAULT_FIELDS, allowed: STORE_CATALOG_PRODUCTS_ALLOWED_FIELDS, isList: true }), + filterByValidSalesChannels(), + applyDefaultFilters({ status: ProductStatus.PUBLISHED }), + normalizeDataForContext(), + setPricingContext(), + setTaxContext() + ] + } +] diff --git a/apps/medusa-be/src/api/store/search/autocomplete/route.ts b/apps/medusa-be/src/api/store/search/autocomplete/route.ts new file mode 100644 index 0000000000..e04eff9270 --- /dev/null +++ b/apps/medusa-be/src/api/store/search/autocomplete/route.ts @@ -0,0 +1,176 @@ +import type { Query } from '@medusajs/framework' +import type { MedusaResponse } from '@medusajs/framework/http' +import { ContainerRegistrationKeys, ProductStatus, QueryContext } from '@medusajs/framework/utils' +import { type RequestWithContext, wrapProductsWithTaxPrices } from '@medusajs/medusa/api/store/products/helpers' +import type { MeiliSearchService } from '@rokmohar/medusa-plugin-meilisearch' +import { cleanSearchText } from '../../../../modules/meilisearch/documents' +import { isMeilisearchEnabled } from '../../../../modules/meilisearch/env' +import { loadSearchProfiles, resolveSearchProfile, type SearchProfile, SearchProfileResolutionError } from '../../../../modules/meilisearch/profiles' +import { buildProductResultFilter, expandProductsBySearchMatches, getSalesChannelIds, selectRankedProductIds } from '../../../../modules/meilisearch/search-results' +import { MEILISEARCH } from '../../../../workflows/meilisearch' +import { normalizeProductSalesChannelFilter } from '../../../utils/product-filters' +import { STORE_CATALOG_PRODUCTS_DEFAULT_FIELDS, STORE_CATALOG_PRODUCTS_PRICING_FIELDS } from '../../catalog/products/validators' +import type { StoreSearchAutocompleteSchemaType } from './validators' + +type SearchResponse = { + hits?: unknown[] +} + +const escapeFilterValue = (value: string): string => value.replaceAll('\\', '\\\\').replaceAll('"', '\\"') + +const buildVisibilityFilter = (salesChannelIds: string[], resultFilter: string): string => { + const filters = ['facet_product_status = "' + ProductStatus.PUBLISHED + '"', resultFilter] + + if (salesChannelIds.length === 1) { + filters.push('facet_sales_channel_ids = "' + escapeFilterValue(salesChannelIds[0] ?? '') + '"') + } else if (salesChannelIds.length > 1) { + filters.push('(' + salesChannelIds.map((id) => 'facet_sales_channel_ids = "' + escapeFilterValue(id) + '"').join(' OR ') + ')') + } + + return filters.join(' AND ') +} + +const safeSearch = async ( + service: MeiliSearchService, + index: string, + query: string, + options: Parameters[2] +): Promise => { + try { + return await service.search(index, query, options) + } catch { + return null + } +} + +const getStringId = (value: unknown): string | undefined => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return + } + + const id = (value as { id?: unknown }).id + + if (typeof id === 'string') { + return id + } + + if (typeof id === 'number' && Number.isFinite(id)) { + return String(id) + } + + return +} + +const deduplicateHits = (hits: unknown[] | undefined, field: string): unknown[] => { + const seen = new Set() + const result: unknown[] = [] + + for (const hit of hits ?? []) { + if (!hit || typeof hit !== 'object' || Array.isArray(hit)) { + continue + } + + const value = (hit as Record)[field] + const key = typeof value === 'string' ? cleanSearchText(value).toLocaleLowerCase() : getStringId(hit) + + if (!key || !seen.has(key)) { + if (key) { + seen.add(key) + } + + result.push(hit) + } + } + + return result +} + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: This endpoint coordinates four independent indices, authoritative product hydration, and degraded fallback. +export async function GET(request: RequestWithContext, response: MedusaResponse) { + if (!isMeilisearchEnabled()) { + response.status(503).json({ message: 'Catalog search is disabled' }) + + return + } + + const query = cleanSearchText(request.validatedQuery.q) + const salesChannelIds = getSalesChannelIds(request.filterableFields.sales_channel_id) + + let profile: SearchProfile + + try { + profile = resolveSearchProfile({ locale: request.validatedQuery.locale, requestedKey: request.validatedQuery.profile, salesChannelIds: salesChannelIds }, await loadSearchProfiles(request.scope)) + } catch (error) { + if (error instanceof SearchProfileResolutionError) { + response.status(400).json({ message: error.message }) + + return + } + + throw error + } + + const meilisearch = request.scope.resolve(MEILISEARCH) + const productCandidateLimit = query ? Math.min(profile.limits.fullSearch, Math.max(profile.limits.autocomplete.product * 4, 24)) : profile.limits.popular + const emptyEntitySearch = Promise.resolve({ hits: [] }) + const productResultFilter = buildProductResultFilter(profile.separateVariantResults, query) + + const [productSearch, categorySearch, brandSearch, contentSearch] = await Promise.all([ + safeSearch(meilisearch, profile.indexes.product, query, { paginationOptions: { limit: productCandidateLimit, offset: 0 }, filter: buildVisibilityFilter(salesChannelIds, productResultFilter), additionalOptions: { attributesToRetrieve: ['id', 'title', 'brand', 'search_product_id', 'search_variant_id', 'search_variant_title', 'search_variant_titles', 'search_identifiers_normalized'], ...(query ? { showRankingScore: true } : { sort: ['facet_popularity:desc'] }) } }), + query ? safeSearch(meilisearch, profile.indexes.category, query, { paginationOptions: { limit: profile.limits.autocomplete.category, offset: 0 }, additionalOptions: { attributesToRetrieve: ['id', 'name', 'handle'] } }) : emptyEntitySearch, + query ? safeSearch(meilisearch, profile.indexes.brand, query, { paginationOptions: { limit: profile.limits.autocomplete.brand, offset: 0 }, additionalOptions: { attributesToRetrieve: ['id', 'title', 'handle'] } }) : emptyEntitySearch, + query ? safeSearch(meilisearch, profile.indexes.content, query, { paginationOptions: { limit: profile.limits.autocomplete.content, offset: 0 }, additionalOptions: { attributesToRetrieve: ['id', 'type', 'title', 'excerpt', 'href'] } }) : emptyEntitySearch + ]) + + const rankedProducts = selectRankedProductIds(productSearch?.hits, query, profile.minimumRankingScore, profile.strict) + const productResultLimit = query ? profile.limits.autocomplete.product : profile.limits.popular + const productMatches = rankedProducts.matches.slice(0, productResultLimit) + const productIds = [...new Set(productMatches.map((match) => match.productId))] + const queryService = request.scope.resolve(ContainerRegistrationKeys.QUERY) + const remoteQuery = request.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) + const pricingContext = request.pricingContext ? QueryContext(request.pricingContext) : undefined + const productFields = pricingContext ? [...STORE_CATALOG_PRODUCTS_DEFAULT_FIELDS, ...STORE_CATALOG_PRODUCTS_PRICING_FIELDS] : STORE_CATALOG_PRODUCTS_DEFAULT_FIELDS + + let degraded = productSearch === null + let products: Record[] = [] + + if (productIds.length > 0) { + const result = await queryService.graph({ entity: 'product', fields: productFields, filters: await normalizeProductSalesChannelFilter(queryService, remoteQuery, { id: { $in: productIds }, sales_channel_id: request.filterableFields.sales_channel_id, status: ProductStatus.PUBLISHED }), context: pricingContext ? { variants: { calculated_price: pricingContext } } : undefined }) + + products = expandProductsBySearchMatches(result.data as Record[], productMatches) + } else if (!productSearch) { + const result = await queryService.graph({ + entity: 'product', + fields: productFields, + filters: await normalizeProductSalesChannelFilter(queryService, remoteQuery, { q: query, sales_channel_id: request.filterableFields.sales_channel_id, status: ProductStatus.PUBLISHED }), + + pagination: { + take: productResultLimit, + skip: 0 + }, + + context: pricingContext ? { + variants: { + calculated_price: pricingContext + } + } : undefined + }) + + products = result.data as Record[] + } + + await wrapProductsWithTaxPrices(request, products as unknown as Parameters[1]) + + degraded ||= [categorySearch, brandSearch, contentSearch].some((result) => result === null) + + response.json({ + query: query, + profile: profile.key, + degraded: degraded, + exactIdentifierMatch: rankedProducts.exactIdentifierMatch, + products: products, + categories: deduplicateHits(categorySearch?.hits, 'name'), + brands: deduplicateHits(brandSearch?.hits, 'title'), + content: deduplicateHits(contentSearch?.hits, 'href') + }) +} diff --git a/apps/medusa-be/src/api/store/search/autocomplete/validators.ts b/apps/medusa-be/src/api/store/search/autocomplete/validators.ts new file mode 100644 index 0000000000..7a6141723d --- /dev/null +++ b/apps/medusa-be/src/api/store/search/autocomplete/validators.ts @@ -0,0 +1,15 @@ +import { z as zod } from '@medusajs/framework/zod' + +export const StoreSearchAutocompleteSchema = zod + .object({ + q: zod.string().trim().max(120), + profile: zod.string().trim().min(1).max(120).optional(), + locale: zod.string().trim().min(2).max(20).optional(), + region_id: zod.string().optional(), + currency_code: zod.string().optional(), + country_code: zod.string().optional(), + sales_channel_id: zod.union([zod.string(), zod.array(zod.string())]).optional() + }) + .strict() + +export type StoreSearchAutocompleteSchemaType = zod.infer diff --git a/apps/medusa-be/src/config/modules.ts b/apps/medusa-be/src/config/modules.ts index 63f03b9694..254de67e8e 100644 --- a/apps/medusa-be/src/config/modules.ts +++ b/apps/medusa-be/src/config/modules.ts @@ -247,6 +247,10 @@ export function buildModules(env: MedusaConfigEnv): MedusaModulesConfig { { resolve: "./src/modules/product-review", }, + { + resolve: "./src/modules/search-profile", + dependencies: [Modules.CACHING], + }, { resolve: "./src/modules/storefront-text", }, diff --git a/apps/medusa-be/src/jobs/meilisearch-full-sync.ts b/apps/medusa-be/src/jobs/meilisearch-full-sync.ts new file mode 100644 index 0000000000..9929115004 --- /dev/null +++ b/apps/medusa-be/src/jobs/meilisearch-full-sync.ts @@ -0,0 +1,11 @@ +import type { MedusaContainer } from '@medusajs/framework/types' +import { synchronizeSearchProfiles } from '../modules/meilisearch/synchronize' + +export default async function meilisearchFullSyncJob(container: MedusaContainer) { + await synchronizeSearchProfiles(container, 'full') +} + +export const config = { + name: 'meilisearch-full-sync', + schedule: '0 3 * * *' +} diff --git a/apps/medusa-be/src/jobs/meilisearch-normal-sync.ts b/apps/medusa-be/src/jobs/meilisearch-normal-sync.ts new file mode 100644 index 0000000000..60c6cd5f54 --- /dev/null +++ b/apps/medusa-be/src/jobs/meilisearch-normal-sync.ts @@ -0,0 +1,11 @@ +import type { MedusaContainer } from '@medusajs/framework/types' +import { synchronizeSearchProfiles } from '../modules/meilisearch/synchronize' + +export default async function meilisearchNormalSyncJob(container: MedusaContainer) { + await synchronizeSearchProfiles(container, 'normal') +} + +export const config = { + name: 'meilisearch-normal-sync', + schedule: '0 */4 * * *' +} diff --git a/apps/medusa-be/src/jobs/meilisearch-search-profiles-index.ts b/apps/medusa-be/src/jobs/meilisearch-search-profiles-index.ts new file mode 100644 index 0000000000..98f64f8fb1 --- /dev/null +++ b/apps/medusa-be/src/jobs/meilisearch-search-profiles-index.ts @@ -0,0 +1,12 @@ +import type { MedusaContainer } from '@medusajs/framework/types' +import { synchronizeSearchProfiles } from '../modules/meilisearch/synchronize' + +export default async function meilisearchSearchProfilesIndexJob(container: MedusaContainer) { + await synchronizeSearchProfiles(container, 'normal') +} + +export const config = { + name: 'meilisearch-search-profiles-index', + schedule: '* * * * *', + numberOfExecutions: 1 +} diff --git a/apps/medusa-be/src/modules/meilisearch/admin-client.ts b/apps/medusa-be/src/modules/meilisearch/admin-client.ts new file mode 100644 index 0000000000..d465cd921e --- /dev/null +++ b/apps/medusa-be/src/modules/meilisearch/admin-client.ts @@ -0,0 +1,261 @@ +import { MedusaError } from '@medusajs/framework/utils' + +type MeilisearchTask = { + error?: { + message?: string + } + + status?: string + taskUid?: number + uid?: number +} + +type MeilisearchDocumentsResponse = { + results?: Array<{ + id?: string | number + }> +} + +type MeilisearchDocumentResponse = { + id?: string | number +} + +type MeilisearchHealthResponse = { + status?: string +} + +type MeilisearchRequestOptions = { + acceptedStatuses?: number[] + attempts?: number + body?: unknown + method: string + path: string +} + +class MeilisearchHttpError extends Error { + readonly retryable: boolean + + constructor(message: string, retryable: boolean) { + super(message) + + this.retryable = retryable + } +} + +const DEFAULT_TASK_TIMEOUT_MILLISECONDS = 60000 +const TASK_POLL_INTERVAL_MILLISECONDS = 75 +const REQUEST_TIMEOUT_MILLISECONDS = 10000 +const REQUEST_ATTEMPTS = 3 +const TRAILING_SLASH_REGEX = /\/$/ + +export class MeilisearchAdminClient { + private readonly apiKey: string + private readonly baseUrl: string + + constructor(options?: { apiKey?: string; host?: string }) { + const host = options?.host ?? process.env.MEILISEARCH_HOST + const apiKey = options?.apiKey ?? process.env.MEILISEARCH_API_KEY + + if (!host) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, 'MEILISEARCH_HOST is required for profile indexing') + } + + if (!apiKey) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, 'MEILISEARCH_API_KEY is required for profile indexing') + } + + this.baseUrl = host.replace(TRAILING_SLASH_REGEX, '') + this.apiKey = apiKey + } + + private async requestOnce(options: MeilisearchRequestOptions): Promise { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MILLISECONDS) + + try { + const response = await fetch(this.baseUrl + options.path, { method: options.method, headers: { Authorization: 'Bearer ' + this.apiKey, ...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }) }, body: options.body === undefined ? undefined : JSON.stringify(options.body), signal: controller.signal }) + const raw = await response.text() + + let parsed: unknown + + try { + parsed = raw ? JSON.parse(raw) : undefined + } catch { + parsed = undefined + } + + if (response.ok || (options.acceptedStatuses ?? []).includes(response.status)) { + return parsed as ResponseBody + } + + const parsedMessage = parsed && typeof parsed === 'object' && !Array.isArray(parsed) && typeof (parsed as { message?: unknown }).message === 'string' ? (parsed as { message: string }).message : raw + + throw new MeilisearchHttpError('Meilisearch ' + options.method + ' ' + options.path + ' failed (' + response.status + '): ' + parsedMessage, response.status === 429 || response.status >= 500) + } finally { + clearTimeout(timeoutId) + } + } + + private async request(options: MeilisearchRequestOptions): Promise { + const attempts = options.attempts ?? REQUEST_ATTEMPTS + + let lastError: unknown + + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + return await this.requestOnce(options) + } catch (error) { + lastError = error + + if (error instanceof MeilisearchHttpError && !error.retryable) { + throw error + } + } + + if (attempt + 1 < attempts) { + await new Promise((resolve) => setTimeout(resolve, 100 * 2 ** attempt)) + } + } + + throw lastError ?? new Error('Meilisearch ' + options.method + ' ' + options.path + ' failed without a response') + } + + private async enqueue(options: MeilisearchRequestOptions): Promise { + const task = await this.request(options) + const taskUid = task.taskUid ?? task.uid + + if (typeof taskUid === 'number') { + await this.waitForTask(taskUid) + } + } + + async waitForTask(taskUid: number, timeoutMilliseconds = DEFAULT_TASK_TIMEOUT_MILLISECONDS): Promise { + const deadline = Date.now() + timeoutMilliseconds + + while (Date.now() < deadline) { + const task = await this.request({ method: 'GET', path: '/tasks/' + taskUid, acceptedStatuses: [404] }) + + if (task.status === 'succeeded') { + return + } + + if (task.status === 'failed' || task.status === 'canceled') { + throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, 'Meilisearch task ' + taskUid + ' ' + task.status + ': ' + (task.error?.message ?? 'unknown error')) + } + + await new Promise((resolve) => setTimeout(resolve, TASK_POLL_INTERVAL_MILLISECONDS)) + } + + throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, 'Meilisearch task ' + taskUid + ' timed out after ' + timeoutMilliseconds + 'ms') + } + + async ensureIndex(index: string): Promise { + try { + await this.enqueue({ method: 'POST', path: '/indexes', body: { uid: index, primaryKey: 'id' } }) + } catch (error) { + if (error instanceof Error && (error.message.includes('index_already_exists') || error.message.toLowerCase().includes('already exists'))) { + return + } + + throw error + } + } + + async health(): Promise { + return this.request({ method: 'GET', path: '/health', attempts: 1 }) + } + + async updateSettings(index: string, settings: Record): Promise { + await this.enqueue({ method: 'PATCH', path: '/indexes/' + encodeURIComponent(index) + '/settings', body: settings }) + } + + async addDocuments(index: string, documents: Record[]): Promise { + if (documents.length === 0) { + return + } + + await this.enqueue({ method: 'POST', path: '/indexes/' + encodeURIComponent(index) + '/documents?primaryKey=id', body: documents }) + } + + async deleteDocuments(index: string, ids: string[]): Promise { + if (ids.length === 0) { + return + } + + await this.enqueue({ method: 'POST', path: '/indexes/' + encodeURIComponent(index) + '/documents/delete-batch', body: ids }) + } + + async getDocumentIds(index: string, batchSize = 1000): Promise { + const ids: string[] = [] + + let offset = 0 + + while (true) { + const result = await this.request({ method: 'GET', path: '/indexes/' + encodeURIComponent(index) + '/documents?fields=id&limit=' + batchSize + '&offset=' + offset }) + const batch = result.results ?? [] + + for (const document of batch) { + if (typeof document.id === 'string') { + ids.push(document.id) + } else if (typeof document.id === 'number') { + ids.push(String(document.id)) + } + } + + if (batch.length < batchSize) { + break + } + + offset += batch.length + } + + return ids + } + + async waitForDocument(index: string, documentId: string, timeoutMilliseconds = DEFAULT_TASK_TIMEOUT_MILLISECONDS): Promise { + const deadline = Date.now() + timeoutMilliseconds + + while (Date.now() < deadline) { + const document = await this.request({ method: 'GET', path: '/indexes/' + encodeURIComponent(index) + '/documents/' + encodeURIComponent(documentId), acceptedStatuses: [404], attempts: 1 }) + + if (String(document.id) === documentId) { + return + } + + await new Promise((resolve) => setTimeout(resolve, TASK_POLL_INTERVAL_MILLISECONDS)) + } + + throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, 'Meilisearch document ' + documentId + ' did not become visible in ' + index + ' after ' + timeoutMilliseconds + 'ms') + } + + async swapIndexPairs( + pairs: Array<{ first: string; second: string }>, + + completionProbe?: { + documentId: string + index: string + } + ): Promise { + if (pairs.length === 0) { + return + } + + const task = await this.request({ method: 'POST', path: '/swap-indexes', body: pairs.map(({ first, second }) => ({ indexes: [first, second] })), attempts: 1 }) + + if (completionProbe) { + await this.waitForDocument(completionProbe.index, completionProbe.documentId) + + return + } + + const taskUid = task.taskUid ?? task.uid + + if (typeof taskUid === 'number') { + await this.waitForTask(taskUid) + } + } + + async deleteIndex(index: string): Promise { + await this.enqueue({ method: 'DELETE', path: '/indexes/' + encodeURIComponent(index), acceptedStatuses: [404] }) + } +} diff --git a/apps/medusa-be/src/modules/meilisearch/content-events.ts b/apps/medusa-be/src/modules/meilisearch/content-events.ts new file mode 100644 index 0000000000..06aa91e1f4 --- /dev/null +++ b/apps/medusa-be/src/modules/meilisearch/content-events.ts @@ -0,0 +1,66 @@ +import type { Logger, MedusaContainer } from '@medusajs/framework/types' +import { MeilisearchAdminClient } from './admin-client' +import { buildContentSearchDocument, cleanSearchText } from './documents' +import { isMeilisearchEnabled } from './env' +import { loadSearchProfiles } from './profiles' +import { CONTENT_INDEX_SETTINGS } from './settings' + +export type CmsSearchChange = { + collection: string + doc?: Record + operation?: string +} + +const SEARCHABLE_COLLECTION_TYPES = { + articles: 'article', + pages: 'page' +} as const + +const normalizeLocale = (value: string): string => value.trim().toLowerCase().replaceAll('_', '-').split('-')[0] ?? '' + +const isPublished = (change: CmsSearchChange, type: 'article' | 'page'): boolean => change.operation !== 'delete' && change.doc?.status === 'published' && (type === 'article' || change.doc?.visibility === 'public') + +export const reconcileContentSearchChange = async (change: CmsSearchChange, logger: Logger, container: MedusaContainer): Promise => { + if (!isMeilisearchEnabled()) { + return + } + + const type = SEARCHABLE_COLLECTION_TYPES[change.collection as keyof typeof SEARCHABLE_COLLECTION_TYPES] + + if (!type) { + return + } + + const rawId = change.doc?.id + + if ((typeof rawId !== 'string' || !rawId.trim()) && (typeof rawId !== 'number' || !Number.isFinite(rawId))) { + logger.warn('Skipping ' + change.collection + ' search projection because the document id is missing') + + return + } + + const locale = typeof change.doc?.locale === 'string' ? normalizeLocale(change.doc.locale) : undefined + const profiles = (await loadSearchProfiles(container)).filter((profile) => !locale || normalizeLocale(profile.locale) === locale) + const client = new MeilisearchAdminClient() + const documentId = type + '_' + String(rawId) + + for (const profile of profiles) { + const index = profile.indexes.content + + await client.ensureIndex(index) + await client.updateSettings(index, CONTENT_INDEX_SETTINGS as Record) + + if (isPublished(change, type)) { + const contentSource = { + ...change.doc, + slug: typeof change.doc?.slug === 'string' ? cleanSearchText(change.doc.slug) : change.doc?.slug + } + + const document = buildContentSearchDocument(contentSource, type, profile.locale) + + await client.addDocuments(index, [document]) + } else { + await client.deleteDocuments(index, [documentId]) + } + } +} diff --git a/apps/medusa-be/src/modules/meilisearch/documents.ts b/apps/medusa-be/src/modules/meilisearch/documents.ts new file mode 100644 index 0000000000..1209bd8cd0 --- /dev/null +++ b/apps/medusa-be/src/modules/meilisearch/documents.ts @@ -0,0 +1,259 @@ +import { createHash } from 'node:crypto' +import { buildProductFacetDocument } from './facets/product-facets' + +type UnknownRecord = Record + +const PRODUCT_IDENTIFIER_METADATA_FIELDS = ['user_code', 'source_sku', 'manufacturer_code', 'external_id', 'legacy_id', 'symmy_id'] as const +const PRODUCT_IDENTIFIER_FIELDS = ['id', 'ean', 'upc', 'barcode', 'sku'] as const +const POPULARITY_FIELDS = ['selled', 'sold_count', 'sales_count', 'popularity'] as const +const TRADEMARK_SYMBOLS_REGEX = /[®™]/g +const HTML_TAG_REGEX = /<[^>]*>/g +const MEILISEARCH_DOCUMENT_ID_REGEX = /^[a-zA-Z0-9_-]+$/ +const MEILISEARCH_DOCUMENT_ID_MAX_BYTES = 511 + +const asRecord = (value: unknown): UnknownRecord | undefined => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return + } + + return value as UnknownRecord +} + +const asRecords = (value: unknown): UnknownRecord[] => Array.isArray(value) ? value.map((entry) => asRecord(entry)).filter((entry): entry is UnknownRecord => Boolean(entry)) : [] + +const addString = (values: string[], value: unknown) => { + if (typeof value === 'string' && value.trim()) { + values.push(value.trim()) + } else if (typeof value === 'number' && Number.isFinite(value)) { + values.push(String(value)) + } +} + +const dedupeStrings = (values: string[]): string[] => Array.from(new Set(values.filter(Boolean))) + +const toNumber = (value: unknown): number => { + if (typeof value === 'number') { + return value + } + + if (typeof value === 'string') { + return Number.parseFloat(value) + } + + return Number.NaN +} + +export const cleanSearchText = (value: string): string => value.replaceAll(TRADEMARK_SYMBOLS_REGEX, ' ').replaceAll(/\s+/g, ' ').trim() + +export const normalizeSearchIdentifier = (value: string): string => cleanSearchText(value).normalize('NFKC').toLocaleLowerCase() + +const collectMetadataIdentifiers = (metadata: UnknownRecord | undefined, identifiers: string[]) => { + for (const field of PRODUCT_IDENTIFIER_METADATA_FIELDS) { + addString(identifiers, metadata?.[field]) + } +} + +const collectRecordSearchIdentifiers = (document: UnknownRecord): string[] => { + const identifiers: string[] = [] + + for (const field of PRODUCT_IDENTIFIER_FIELDS) { + addString(identifiers, document[field]) + } + + collectMetadataIdentifiers(asRecord(document.metadata), identifiers) + + return identifiers +} + +export const collectProductSearchIdentifiers = (document: UnknownRecord): string[] => { + const identifiers = collectRecordSearchIdentifiers(document) + + for (const variant of asRecords(document.variants)) { + identifiers.push(...collectRecordSearchIdentifiers(variant)) + } + + return dedupeStrings(identifiers) +} + +const collectVariantSearchIdentifiers = (document: UnknownRecord, variant: UnknownRecord): string[] => dedupeStrings([ + ...collectRecordSearchIdentifiers(document), + ...collectRecordSearchIdentifiers(variant) +]) + +const getSearchDocumentId = (value: unknown): string => { + if (typeof value === 'string' || typeof value === 'number') { + return String(value) + } + + return '' +} + +const readPopularity = (document: UnknownRecord): number => { + const metadata = asRecord(document.metadata) + + for (const field of POPULARITY_FIELDS) { + const value = metadata?.[field] ?? document[field] + const parsed = toNumber(value) + + if (Number.isFinite(parsed)) { + return Math.max(0, parsed) + } + } + + return 0 +} + +const cleanValue = (value: unknown): unknown => { + if (typeof value === 'string') { + return cleanSearchText(value) + } + + if (Array.isArray(value)) { + const entries = value + .map((entry) => cleanValue(entry)) + .filter((entry) => entry !== undefined && entry !== null && entry !== '' && (!Array.isArray(entry) || entry.length > 0)) + + return entries.length > 0 ? entries : undefined + } + + const record = asRecord(value) + + if (record) { + const entries = Object.entries(record) + .map(([key, entry]) => [key, cleanValue(entry)] as const) + .filter(([, entry]) => entry !== undefined && entry !== null && entry !== '') + + return entries.length > 0 ? Object.fromEntries(entries) : undefined + } + + return value ?? undefined +} + +export const cleanSearchDocument = (document: DocumentType): DocumentType => (cleanValue(document) ?? {}) as DocumentType + +export const buildProductSearchDocument = ( + document: UnknownRecord, + + options?: { + popularity?: number + } +): UnknownRecord => { + const variants = asRecords(document.variants) + const identifiers = collectProductSearchIdentifiers(document) + + const searchDocument = { + ...document, + ...buildProductFacetDocument(document), + facet_popularity: options?.popularity === undefined ? readPopularity(document) : Math.max(0, options.popularity), + search_identifiers: identifiers, + search_identifiers_normalized: identifiers.map(normalizeSearchIdentifier), + search_has_variants: variants.length > 0, + search_product_id: document.id, + search_result_kind: 'product', + search_variant_titles: variants.map((variant) => variant.title).filter((title): title is string => typeof title === 'string' && Boolean(title.trim())) + } + + return cleanSearchDocument(searchDocument) +} + +const buildVariantDocumentId = (productId: unknown, variantId: unknown): string => { + const raw = 'variant_' + getSearchDocumentId(productId) + '_' + getSearchDocumentId(variantId) + + if (MEILISEARCH_DOCUMENT_ID_REGEX.test(raw) && Buffer.byteLength(raw) <= MEILISEARCH_DOCUMENT_ID_MAX_BYTES) { + return raw + } + + const digest = createHash('sha256').update(raw).digest('hex').slice(0, 16) + + return 'variant_' + digest +} + +const buildProductVariantSearchDocument = (document: UnknownRecord, variant: UnknownRecord, popularity: number | undefined): UnknownRecord => { + const identifiers = collectVariantSearchIdentifiers(document, variant) + + return cleanSearchDocument({ + ...document, + ...buildProductFacetDocument({ ...document, variants: [variant] }), + id: buildVariantDocumentId(document.id, variant.id), + facet_popularity: popularity === undefined ? readPopularity(document) : Math.max(0, popularity), + search_identifiers: identifiers, + search_identifiers_normalized: identifiers.map(normalizeSearchIdentifier), + search_has_variants: true, + search_product_id: document.id, + search_result_kind: 'variant', + search_variant_id: variant.id, + search_variant_title: variant.title, + search_variant_titles: typeof variant.title === 'string' ? [variant.title] : [], + variants: [variant] + }) +} + +export const buildProductSearchDocuments = (document: UnknownRecord, options?: { popularity?: number }): UnknownRecord[] => { + const variants = asRecords(document.variants) + + return [ + buildProductSearchDocument(document, options), + ...variants.filter((variant) => Boolean(getSearchDocumentId(variant.id))).map((variant) => buildProductVariantSearchDocument(document, variant, options?.popularity)) + ] +} + +export const buildCategorySearchDocument = (document: UnknownRecord): UnknownRecord => + cleanSearchDocument({ + id: document.id, + name: document.name, + description: document.description, + handle: document.handle, + parent_category_id: document.parent_category_id ?? asRecord(document.parent_category)?.id + }) + +export const buildBrandSearchDocument = (document: UnknownRecord): UnknownRecord => cleanSearchDocument({ id: document.id, title: document.title, description: document.description, handle: document.handle }) + +const extractContentText = (value: unknown): string => { + if (typeof value === 'string') { + return cleanSearchText(value.replaceAll(HTML_TAG_REGEX, ' ')) + } + + if (Array.isArray(value)) { + return cleanSearchText(value.map(extractContentText).join(' ')) + } + + const record = asRecord(value) + + if (!record) { + return '' + } + + return cleanSearchText(Object.values(record).map(extractContentText).join(' ')) +} + +const buildContentDocumentId = (type: 'article' | 'page', sourceId: unknown): string => { + const raw = String(sourceId) + const direct = type + '_' + raw + + if (MEILISEARCH_DOCUMENT_ID_REGEX.test(direct) && Buffer.byteLength(direct) <= MEILISEARCH_DOCUMENT_ID_MAX_BYTES) { + return direct + } + + const digest = createHash('sha256').update(raw).digest('hex').slice(0, 16) + const readable = raw.replaceAll(/[^a-zA-Z0-9_-]/g, '_') || 'id' + const readableBudget = MEILISEARCH_DOCUMENT_ID_MAX_BYTES - Buffer.byteLength(type) - Buffer.byteLength(digest) - 2 + + return type + '_' + readable.slice(0, readableBudget) + '_' + digest +} + +export const buildContentSearchDocument = (document: UnknownRecord, type: 'article' | 'page', locale: string): UnknownRecord => { + const slug = typeof document.slug === 'string' ? document.slug : '' + const href = type === 'article' ? '/blog/' + slug : '/' + slug + + return cleanSearchDocument({ + id: buildContentDocumentId(type, document.id), + source_id: String(document.id), + type: type, + locale: locale, + title: document.title, + excerpt: document.excerpt, + content: extractContentText(document.contentHTML ?? document.content ?? document.excerpt), + slug: slug, + href: href + }) +} diff --git a/apps/medusa-be/src/modules/meilisearch/medusa-config.ts b/apps/medusa-be/src/modules/meilisearch/medusa-config.ts index 641852b67a..c8ee953072 100644 --- a/apps/medusa-be/src/modules/meilisearch/medusa-config.ts +++ b/apps/medusa-be/src/modules/meilisearch/medusa-config.ts @@ -1,17 +1,15 @@ import type { MedusaConfigEnv } from "../../config/env" import type { MedusaPluginConfig } from "../../config/types" -import { buildProductFacetDocument } from "./facets/product-facets" - -const MEILISEARCH_TYPO_TOLERANCE_SETTINGS = { - enabled: true, - minWordSizeForTypos: { - oneTypo: 4, - twoTypos: 10, - }, - disableOnWords: [], - disableOnAttributes: [], - disableOnNumbers: false, -} +import { + buildBrandSearchDocument, + buildCategorySearchDocument, + buildProductSearchDocument, +} from "./documents" +import { + BRAND_INDEX_SETTINGS, + CATEGORY_INDEX_SETTINGS, + PRODUCT_INDEX_SETTINGS, +} from "./settings" export function buildMeilisearchPlugin( env: MedusaConfigEnv @@ -45,64 +43,14 @@ export function buildMeilisearchPlugin( "sales_channels.id", "variants.id", "variants.sku", + "variants.ean", + "variants.upc", + "variants.barcode", + "variants.metadata", "variants.prices.amount", "variants.prices.currency_code", ], - indexSettings: { - searchableAttributes: [ - "title", - "description", - "handle", - "brand.title", - "categories.name", - "variants.sku", - ], - displayedAttributes: [ - "id", - "status", - "title", - "description", - "thumbnail", - "handle", - "created_at", - "metadata", - "brand", - "categories", - "sales_channels", - "facet_product_status", - "facet_sales_channel_ids", - "facet_status", - "facet_form", - "facet_brand", - "facet_ingredient", - "facet_category_ids", - "facet_in_stock", - "facet_price", - ], - filterableAttributes: [ - "id", - "handle", - "facet_product_status", - "facet_sales_channel_ids", - "facet_status", - "facet_form", - "facet_brand", - "facet_ingredient", - "facet_category_ids", - "facet_in_stock", - "facet_price", - ], - sortableAttributes: ["created_at", "title", "facet_price"], - typoTolerance: MEILISEARCH_TYPO_TOLERANCE_SETTINGS, - rankingRules: [ - "sort", - "words", - "typo", - "proximity", - "attribute", - "exactness", - ], - }, + indexSettings: PRODUCT_INDEX_SETTINGS, transformer: async ( document: Record, defaultTransformer: ( @@ -111,35 +59,30 @@ export function buildMeilisearchPlugin( ) => { const transformedDocument = defaultTransformer(document) - return { - ...transformedDocument, - ...buildProductFacetDocument(transformedDocument), - } + return buildProductSearchDocument(transformedDocument) }, primaryKey: "id", }, categories: { type: "categories", enabled: true, - fields: ["id", "description", "handle"], - indexSettings: { - searchableAttributes: ["description"], - displayedAttributes: ["id", "description", "handle"], - filterableAttributes: ["id", "handle", "description"], - typoTolerance: MEILISEARCH_TYPO_TOLERANCE_SETTINGS, - }, + fields: ["id", "name", "description", "handle", "parent_category_id"], + indexSettings: CATEGORY_INDEX_SETTINGS, + transformer: ( + document: Record, + defaultTransformer: ( + input: Record + ) => Record + ) => buildCategorySearchDocument(defaultTransformer(document)), primaryKey: "id", }, brands: { type: "brands", enabled: true, - fields: ["id", "title", "handle"], - indexSettings: { - searchableAttributes: ["title", "handle"], - displayedAttributes: ["id", "title", "handle"], - filterableAttributes: ["id", "title", "handle"], - typoTolerance: MEILISEARCH_TYPO_TOLERANCE_SETTINGS, - }, + fields: ["id", "title", "description", "handle"], + indexSettings: BRAND_INDEX_SETTINGS, + transformer: (document: Record) => + buildBrandSearchDocument(document), primaryKey: "id", }, }, diff --git a/apps/medusa-be/src/modules/meilisearch/profiles.ts b/apps/medusa-be/src/modules/meilisearch/profiles.ts new file mode 100644 index 0000000000..7747f6e8d7 --- /dev/null +++ b/apps/medusa-be/src/modules/meilisearch/profiles.ts @@ -0,0 +1,371 @@ +import type { MedusaContainer } from '@medusajs/framework/types' +import { SEARCH_PROFILE_MODULE, type SearchProfileDTO, type SearchProfileModuleService } from '../search-profile' + +export const SEARCH_INDEX_TYPES = ['product', 'category', 'brand', 'content'] as const + +export type SearchIndexType = (typeof SEARCH_INDEX_TYPES)[number] + +export type SearchProfileLimits = { + autocomplete: Record + fullSearch: number + page: number + popular: number +} + +export type SearchProfile = { + availability: 'all' | 'in-stock' + domain: string + id?: string + indexes: Record + key: string + limits: SearchProfileLimits + locale: string + minimumRankingScore: number + salesChannelIds: string[] + separateVariantResults: boolean + shop: string + strict: boolean +} + +type RawSearchProfile = { + availability?: unknown + domain?: unknown + key?: unknown + limits?: unknown + locale?: unknown + minimumRankingScore?: unknown + salesChannelIds?: unknown + separateVariantResults?: unknown + shop?: unknown + strict?: unknown +} + +export type SearchProfileSelection = { + locale?: string + requestedKey?: string + salesChannelIds?: string[] +} + +export class SearchProfileConfigurationError extends Error {} + +export class SearchProfileResolutionError extends Error {} + +const DEFAULT_AUTOCOMPLETE_LIMITS: Record = { + product: 6, + category: 3, + brand: 3, + content: 3 +} + +const DEFAULT_PROFILE: SearchProfile = { + availability: 'all', + domain: 'default', + + indexes: { + product: 'product_default_default_default', + category: 'category_default_default_default', + brand: 'brand_default_default_default', + content: 'content_default_default_default' + }, + + key: 'default', + + limits: { + autocomplete: DEFAULT_AUTOCOMPLETE_LIMITS, + fullSearch: 500, + page: 100, + popular: 12 + }, + + locale: 'default', + minimumRankingScore: 0, + salesChannelIds: [], + separateVariantResults: false, + shop: 'default', + strict: false +} + +const normalizeSegment = (value: string, field: string): string => { + const normalized = value + .trim() + .toLowerCase() + .normalize('NFKD') + .replaceAll(/\p{Diacritic}/gu, '') + .replaceAll(/[^a-z0-9_-]+/g, '-') + .replaceAll(/-+/g, '-') + .replaceAll(/^-|-$/g, '') + + if (!normalized) { + throw new SearchProfileConfigurationError('Meilisearch search profile ' + field + ' must contain an index-safe value') + } + + return normalized +} + +const readRequiredString = (value: unknown, field: string, profileIndex: number): string => { + if (typeof value !== 'string' || !value.trim()) { + throw new SearchProfileConfigurationError('MEILISEARCH_SEARCH_PROFILES[' + profileIndex + '].' + field + ' must be a non-empty string') + } + + return value.trim() +} + +const readBoolean = (value: unknown, fallback: boolean, field: string, profileIndex: number): boolean => { + if (value === undefined) { + return fallback + } + + if (typeof value !== 'boolean') { + throw new SearchProfileConfigurationError('MEILISEARCH_SEARCH_PROFILES[' + profileIndex + '].' + field + ' must be a boolean') + } + + return value +} + +const readLimitedInteger = (options: { fallback: number; field: string; maximum: number; profileIndex: number; value: unknown }): number => { + if (options.value === undefined) { + return options.fallback + } + + if (typeof options.value !== 'number' || !Number.isInteger(options.value) || options.value < 1 || options.value > options.maximum) { + throw new SearchProfileConfigurationError('MEILISEARCH_SEARCH_PROFILES[' + options.profileIndex + '].' + options.field + ' must be an integer between 1 and ' + options.maximum) + } + + return options.value +} + +const readRankingScore = (value: unknown, strict: boolean, profileIndex: number): number => { + if (value === undefined) { + return strict ? 0.98 : 0.55 + } + + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) { + throw new SearchProfileConfigurationError('MEILISEARCH_SEARCH_PROFILES[' + profileIndex + '].minimumRankingScore must be between 0 and 1') + } + + return value +} + +const readAvailability = (value: unknown, profileIndex: number): SearchProfile['availability'] => { + if (value === undefined) { + return 'all' + } + + if (value === 'all' || value === 'in-stock') { + return value + } + + throw new SearchProfileConfigurationError('MEILISEARCH_SEARCH_PROFILES[' + profileIndex + '].availability must be "all" or "in-stock"') +} + +const readStringArray = (value: unknown, field: string, profileIndex: number): string[] => { + if (value === undefined) { + return [] + } + + if (!Array.isArray(value)) { + throw new SearchProfileConfigurationError('MEILISEARCH_SEARCH_PROFILES[' + profileIndex + '].' + field + ' must be an array') + } + + return Array.from( + new Set( + value.map((entry) => { + if (typeof entry !== 'string' || !entry.trim()) { + throw new SearchProfileConfigurationError('MEILISEARCH_SEARCH_PROFILES[' + profileIndex + '].' + field + ' must contain only non-empty strings') + } + + return entry.trim() + }) + ) + ) +} + +const createIndexNames = (shop: string, domain: string, locale: string): Record => Object.fromEntries(SEARCH_INDEX_TYPES.map((type) => [type, type + '_' + shop + '_' + domain + '_' + locale])) as Record + +const readLimits = (value: unknown, profileIndex: number): SearchProfileLimits => { + const raw = value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {} + const rawAutocomplete = raw.autocomplete && typeof raw.autocomplete === 'object' && !Array.isArray(raw.autocomplete) ? (raw.autocomplete as Record) : {} + + return { autocomplete: Object.fromEntries(SEARCH_INDEX_TYPES.map((type) => [ type, readLimitedInteger({ value: rawAutocomplete[type], fallback: DEFAULT_AUTOCOMPLETE_LIMITS[type], field: 'limits.autocomplete.' + type, profileIndex: profileIndex, maximum: 24 }) ])) as Record, fullSearch: readLimitedInteger({ value: raw.fullSearch, fallback: 500, field: 'limits.fullSearch', profileIndex: profileIndex, maximum: 1000 }), page: readLimitedInteger({ value: raw.page, fallback: 100, field: 'limits.page', profileIndex: profileIndex, maximum: 100 }), popular: readLimitedInteger({ value: raw.popular, fallback: 12, field: 'limits.popular', profileIndex: profileIndex, maximum: 48 }) } +} + +const parseProfile = (rawProfile: RawSearchProfile, profileIndex: number): SearchProfile => { + const shop = normalizeSegment(readRequiredString(rawProfile.shop, 'shop', profileIndex), 'shop') + const domain = normalizeSegment(readRequiredString(rawProfile.domain, 'domain', profileIndex), 'domain') + const locale = normalizeSegment(readRequiredString(rawProfile.locale, 'locale', profileIndex), 'locale') + const strict = readBoolean(rawProfile.strict, false, 'strict', profileIndex) + const key = normalizeSegment(typeof rawProfile.key === 'string' && rawProfile.key.trim() ? rawProfile.key : shop + '-' + domain + '-' + locale, 'key') + + return { + availability: readAvailability(rawProfile.availability, profileIndex), + domain: domain, + indexes: createIndexNames(shop, domain, locale), + key: key, + limits: readLimits(rawProfile.limits, profileIndex), + locale: locale, + minimumRankingScore: readRankingScore(rawProfile.minimumRankingScore, strict, profileIndex), + salesChannelIds: readStringArray(rawProfile.salesChannelIds, 'salesChannelIds', profileIndex), + separateVariantResults: readBoolean(rawProfile.separateVariantResults, false, 'separateVariantResults', profileIndex), + shop: shop, + strict: strict + } +} + +const parseProfilesJson = (value: string): unknown[] => { + let parsed: unknown + + try { + parsed = JSON.parse(value) + } catch (error) { + throw new SearchProfileConfigurationError('MEILISEARCH_SEARCH_PROFILES must be valid JSON: ' + (error instanceof Error ? error.message : String(error))) + } + + if (!Array.isArray(parsed) || parsed.length === 0) { + throw new SearchProfileConfigurationError('MEILISEARCH_SEARCH_PROFILES must be a non-empty JSON array') + } + + return parsed +} + +const parseProfileEntry = (entry: unknown, index: number): SearchProfile => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + throw new SearchProfileConfigurationError('MEILISEARCH_SEARCH_PROFILES[' + index + '] must be an object') + } + + return parseProfile(entry as RawSearchProfile, index) +} + +export const parseSearchProfileConfiguration = (entry: unknown, index = 0): SearchProfile => parseProfileEntry(entry, index) + +const assertUniqueProfileValues = (profiles: SearchProfile[], describe: (profile: SearchProfile) => string, label: string) => { + const seen = new Set() + + for (const profile of profiles) { + const value = describe(profile) + + if (seen.has(value)) { + throw new SearchProfileConfigurationError('Duplicate Meilisearch ' + label + ': ' + value) + } + + seen.add(value) + } +} + +export const validateSearchProfileSet = (profiles: SearchProfile[]) => { + assertUniqueProfileValues(profiles, (profile) => profile.key, 'profile key') + assertUniqueProfileValues(profiles, (profile) => profile.shop + ':' + profile.domain + ':' + profile.locale, 'shop/domain/locale profile') + + if (profiles.length > 1 && profiles.some((profile) => profile.salesChannelIds.length === 0)) { + throw new SearchProfileConfigurationError('Every Meilisearch search profile must declare salesChannelIds when more than one domain profile is configured') + } +} + +export const readSearchProfiles = (env: NodeJS.ProcessEnv = process.env): SearchProfile[] => { + const value = env.MEILISEARCH_SEARCH_PROFILES?.trim() + + if (!value) { + return [DEFAULT_PROFILE] + } + + const profiles = parseProfilesJson(value).map(parseProfileEntry) + + validateSearchProfileSet(profiles) + + return profiles +} + +export const persistedSearchProfileToRuntime = (profile: SearchProfileDTO, profileIndex = 0): SearchProfile => ({ + ...parseProfile( + { + availability: profile.availability, + domain: profile.domain, + key: profile.key, + + limits: { + autocomplete: { + product: profile.autocomplete_product_limit, + category: profile.autocomplete_category_limit, + brand: profile.autocomplete_brand_limit, + content: profile.autocomplete_content_limit + }, + + fullSearch: profile.full_search_limit, + page: profile.max_results_per_page, + popular: profile.popular_limit + }, + + locale: profile.locale, + minimumRankingScore: profile.minimum_ranking_score ?? undefined, + salesChannelIds: profile.sales_channel_ids, + separateVariantResults: profile.separate_variant_results, + shop: profile.shop, + strict: profile.strict + }, + + profileIndex + ), + + id: profile.id +}) + +export const loadSearchProfiles = async (container: MedusaContainer): Promise => { + let service: SearchProfileModuleService + + try { + service = container.resolve(SEARCH_PROFILE_MODULE) + } catch { + return [] + } + + if (typeof service.listConfiguredProfiles !== 'function') { + return [] + } + + const persisted = typeof service.listRuntimeProfiles === 'function' ? await service.listRuntimeProfiles() : await service.listConfiguredProfiles({ enabledOnly: true }) + + if (persisted.length === 0) { + return [] + } + + const profiles = persisted.map(persistedSearchProfileToRuntime) + + validateSearchProfileSet(profiles) + + return profiles +} + +const normalizeLocale = (value: string): string => value.trim().toLowerCase().replaceAll('_', '-').split('-')[0] ?? '' + +const hasSalesChannelAccess = (profile: SearchProfile, salesChannelIds: string[]): boolean => profile.salesChannelIds.some((id) => salesChannelIds.includes(id)) + +const sortProfilesByKey = (profiles: SearchProfile[]): SearchProfile[] => [...profiles].sort((left, right) => left.key.localeCompare(right.key)) + +export const resolveSearchProfile = (selection: SearchProfileSelection, profiles: SearchProfile[] = []): SearchProfile => { + const salesChannelIds = selection.salesChannelIds ?? [] + const requestedKey = selection.requestedKey?.trim().toLowerCase() + + if (requestedKey) { + const profile = profiles.find((entry) => entry.key === requestedKey) + + if (!profile) { + throw new SearchProfileResolutionError('Unknown Meilisearch search profile: ' + requestedKey) + } + + if (!hasSalesChannelAccess(profile, salesChannelIds)) { + throw new SearchProfileResolutionError('Search profile ' + requestedKey + ' is not available for this storefront') + } + + return profile + } + + const requestedLocale = selection.locale ? normalizeLocale(selection.locale) : undefined + const accessible = profiles.filter((profile) => hasSalesChannelAccess(profile, salesChannelIds)) + const localized = requestedLocale ? accessible.filter((profile) => normalizeLocale(profile.locale) === requestedLocale) : accessible + const candidates = requestedLocale ? localized : accessible + + if (candidates.length > 0) { + return sortProfilesByKey(candidates)[0] as SearchProfile + } + + throw new SearchProfileResolutionError('No Meilisearch profile is assigned to this storefront Sales Channel and language') +} diff --git a/apps/medusa-be/src/modules/meilisearch/search-results.ts b/apps/medusa-be/src/modules/meilisearch/search-results.ts new file mode 100644 index 0000000000..793f8b702d --- /dev/null +++ b/apps/medusa-be/src/modules/meilisearch/search-results.ts @@ -0,0 +1,201 @@ +import { cleanSearchText, normalizeSearchIdentifier } from './documents' + +export type RankedProductHit = { + _rankingScore?: number + brand?: unknown + id?: string | number + search_product_id?: unknown + search_variant_id?: unknown + search_identifiers_normalized?: unknown + search_variant_title?: unknown + search_variant_titles?: unknown + title?: unknown +} + +export type RankedProductMatch = { + productId: string + variantId?: string +} + +const getStringId = (value: unknown): string | undefined => { + if (typeof value === 'string' && value.trim()) { + return value + } + + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value) + } + + return +} + +const getProductMatch = (hit: RankedProductHit): RankedProductMatch | undefined => { + const productId = getStringId(hit.search_product_id) ?? getStringId(hit.id) + + if (!productId) { + return + } + + const variantId = getStringId(hit.search_variant_id) + + return variantId ? { productId: productId, variantId: variantId } : { productId: productId } +} + +const hasExactIdentifier = (hit: RankedProductHit, normalizedQuery: string): boolean => Array.isArray(hit.search_identifiers_normalized) && hit.search_identifiers_normalized.some((value) => typeof value === 'string' && value === normalizedQuery) + +const getSearchTokens = (value: string): string[] => cleanSearchText(value).normalize('NFKD').replaceAll(/\p{Diacritic}/gu, '').toLocaleLowerCase().split(/[^\p{Letter}\p{Number}]+/u).filter(Boolean) + +const getNormalizedSearchIdentifiers = (hit: RankedProductHit): string[] => { + if (!Array.isArray(hit.search_identifiers_normalized)) { + return [] + } + + return hit.search_identifiers_normalized.filter((value): value is string => typeof value === 'string' && Boolean(value)) +} + +const hasPartialIdentifierMatch = (hit: RankedProductHit, normalizedQuery: string): boolean => Boolean(normalizedQuery) && getNormalizedSearchIdentifiers(hit).some((identifier) => identifier !== normalizedQuery && identifier.includes(normalizedQuery)) + +const isIdentifierLikeQuery = (query: string): boolean => { + const normalizedQuery = normalizeSearchIdentifier(query) + + return /^[\p{Letter}\p{Number}_-]+$/u.test(normalizedQuery) && /[\p{Number}_-]/u.test(normalizedQuery) +} + +const getBrandTitles = (value: unknown): unknown[] => { + if (Array.isArray(value)) { + return value.flatMap(getBrandTitles) + } + + if (value && typeof value === 'object') { + return [(value as { title?: unknown }).title] + } + + return [] +} + +const hasSearchTextPrefix = (hit: RankedProductHit, query: string, includeBrand: boolean): boolean => { + const queryTokens = getSearchTokens(query) + const values = [hit.title, hit.search_variant_title, ...(Array.isArray(hit.search_variant_titles) ? hit.search_variant_titles : []), ...(includeBrand ? getBrandTitles(hit.brand) : [])] + const searchableTokens = values.flatMap((value) => typeof value === 'string' ? getSearchTokens(value) : []) + + return queryTokens.length > 0 && queryTokens.every((queryToken) => searchableTokens.some((searchableToken) => searchableToken.startsWith(queryToken))) +} + +export const isAcceptedProductHit = (rawHit: unknown, query: string, minimumRankingScore: number, strict: boolean): boolean => { + if (!rawHit || typeof rawHit !== 'object' || Array.isArray(rawHit)) { + return false + } + + const hit = rawHit as RankedProductHit + const normalizedQuery = normalizeSearchIdentifier(cleanSearchText(query)) + + if (hasExactIdentifier(hit, normalizedQuery)) { + return true + } + + if (hasSearchTextPrefix(hit, query, !strict)) { + return true + } + + if (hasPartialIdentifierMatch(hit, normalizedQuery)) { + return false + } + + if (isIdentifierLikeQuery(query)) { + return false + } + + return typeof hit._rankingScore !== 'number' || hit._rankingScore >= minimumRankingScore +} + +export const selectRankedProductIds = ( + rawHits: unknown, + query: string, + minimumRankingScore: number, + strict: boolean +): { + exactIdentifierMatch: boolean + ids: string[] + matches: RankedProductMatch[] + selectedHits: RankedProductHit[] +} => { + const hits = Array.isArray(rawHits) ? rawHits.filter((hit): hit is RankedProductHit => Boolean(hit && typeof hit === 'object' && !Array.isArray(hit))) : [] + const cleanedQuery = cleanSearchText(query) + const normalizedQuery = normalizeSearchIdentifier(cleanedQuery) + const scoreFiltered = cleanedQuery ? hits.filter((hit) => isAcceptedProductHit(hit, cleanedQuery, minimumRankingScore, strict)) : hits + const exactHits = normalizedQuery ? hits.filter((hit) => hasExactIdentifier(hit, normalizedQuery)) : [] + const selectedHits = exactHits.length > 0 ? exactHits : scoreFiltered + const matches = selectedHits.map(getProductMatch).filter((match): match is RankedProductMatch => match !== undefined) + + return { exactIdentifierMatch: exactHits.length > 0, ids: matches.map((match) => match.productId), matches: matches, selectedHits: selectedHits } +} + +export const buildProductResultFilter = (separateVariantResults: boolean, query: string): string => separateVariantResults && Boolean(cleanSearchText(query)) ? '(search_result_kind = "variant" OR (search_result_kind = "product" AND search_has_variants = false))' : 'search_result_kind = "product"' + +const getProductRecordId = (product: Record): string | undefined => getStringId(product.id) + +const getVariantTitle = (variant: Record, variantId: string): string => { + for (const field of ['title', 'sku', 'ean', 'upc', 'barcode']) { + const value = variant[field] + + if (typeof value === 'string' && value.trim()) { + return value.trim() + } + } + + return variantId +} + +export const expandProductsBySearchMatches = (products: Record[], matches: RankedProductMatch[]): Record[] => { + const productsById = new Map(products.map((product) => [getProductRecordId(product), product])) + + return matches.flatMap((match) => { + const product = productsById.get(match.productId) + + if (!product) { + return [] + } + + if (!match.variantId || !Array.isArray(product.variants)) { + return [product] + } + + const variants = product.variants.filter((variant): variant is Record => Boolean(variant && typeof variant === 'object' && !Array.isArray(variant))) + const matchedVariant = variants.find((variant) => getStringId(variant.id) === match.variantId) + + if (!matchedVariant) { + return [product] + } + + return [{ + ...product, + + search_result: { + variant_id: match.variantId, + variant_title: getVariantTitle(matchedVariant, match.variantId) + }, + + variants: [matchedVariant, ...variants.filter((variant) => getStringId(variant.id) !== match.variantId)] + }] + }) +} + +export const getSalesChannelIds = (value: unknown): string[] => { + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === 'string') + } + + if (typeof value === 'string') { + return [value] + } + + if (value && typeof value === 'object' && !Array.isArray(value)) { + const inValue = (value as Record).$in + + if (Array.isArray(inValue)) { + return inValue.filter((item): item is string => typeof item === 'string') + } + } + + return [] +} diff --git a/apps/medusa-be/src/modules/meilisearch/settings.ts b/apps/medusa-be/src/modules/meilisearch/settings.ts new file mode 100644 index 0000000000..7b9553c064 --- /dev/null +++ b/apps/medusa-be/src/modules/meilisearch/settings.ts @@ -0,0 +1,106 @@ +const TYPO_TOLERANCE_SETTINGS = { + enabled: true, + + minWordSizeForTypos: { + oneTypo: 4, + twoTypos: 6 + }, + + disableOnWords: [], + disableOnAttributes: ['search_identifiers', 'search_identifiers_normalized'], + disableOnNumbers: true +} + +const COMMON_SETTINGS = { + typoTolerance: TYPO_TOLERANCE_SETTINGS, + + pagination: { + maxTotalHits: 500 + }, + + proximityPrecision: 'byAttribute' +} + +export const PRODUCT_INDEX_SETTINGS = { + ...COMMON_SETTINGS, + searchableAttributes: ['search_identifiers', 'title', 'search_variant_titles', 'brand.title', 'categories.name', 'description', 'handle'], + + displayedAttributes: [ + 'id', + 'status', + 'title', + 'description', + 'thumbnail', + 'handle', + 'created_at', + 'metadata', + 'brand', + 'categories', + 'sales_channels', + 'search_identifiers_normalized', + 'search_has_variants', + 'search_product_id', + 'search_result_kind', + 'search_variant_id', + 'search_variant_title', + 'search_variant_titles', + 'facet_product_status', + 'facet_sales_channel_ids', + 'facet_status', + 'facet_form', + 'facet_brand', + 'facet_ingredient', + 'facet_category_ids', + 'facet_in_stock', + 'facet_price', + 'facet_popularity' + ], + + filterableAttributes: [ + 'id', + 'handle', + 'search_identifiers_normalized', + 'search_has_variants', + 'search_result_kind', + 'facet_product_status', + 'facet_sales_channel_ids', + 'facet_status', + 'facet_form', + 'facet_brand', + 'facet_ingredient', + 'facet_category_ids', + 'facet_in_stock', + 'facet_price' + ], + + sortableAttributes: ['created_at', 'title', 'facet_price', 'facet_popularity'], + rankingRules: ['words', 'typo', 'proximity', 'attributeRank', 'sort', 'wordPosition', 'exactness'] +} + +export const CATEGORY_INDEX_SETTINGS = { + ...COMMON_SETTINGS, + searchableAttributes: ['name', 'description', 'product_titles', 'handle'], + displayedAttributes: ['id', 'name', 'description', 'handle', 'parent_category_id'], + filterableAttributes: ['id', 'handle', 'parent_category_id'] +} + +export const BRAND_INDEX_SETTINGS = { + ...COMMON_SETTINGS, + searchableAttributes: ['title', 'description', 'handle'], + displayedAttributes: ['id', 'title', 'description', 'handle'], + filterableAttributes: ['id', 'handle'] +} + +export const CONTENT_INDEX_SETTINGS = { + ...COMMON_SETTINGS, + searchableAttributes: ['title', 'excerpt', 'content', 'slug'], + displayedAttributes: ['id', 'source_id', 'type', 'locale', 'title', 'excerpt', 'slug', 'href'], + filterableAttributes: ['id', 'source_id', 'type', 'locale', 'slug'] +} + +export const SEARCH_INDEX_SETTINGS = { + product: PRODUCT_INDEX_SETTINGS, + category: CATEGORY_INDEX_SETTINGS, + brand: BRAND_INDEX_SETTINGS, + content: CONTENT_INDEX_SETTINGS +} as const diff --git a/apps/medusa-be/src/modules/meilisearch/synchronize.ts b/apps/medusa-be/src/modules/meilisearch/synchronize.ts new file mode 100644 index 0000000000..30403c2a9e --- /dev/null +++ b/apps/medusa-be/src/modules/meilisearch/synchronize.ts @@ -0,0 +1,671 @@ +import type { ILockingModule, Logger, MedusaContainer, Query } from '@medusajs/framework/types' +import { ContainerRegistrationKeys, Modules } from '@medusajs/framework/utils' +import { PAYLOAD_MODULE } from '../payload' +import type PayloadModuleService from '../payload/service' +import { SEARCH_PROFILE_MODULE } from '../search-profile' +import { MeilisearchAdminClient } from './admin-client' +import { buildBrandSearchDocument, buildCategorySearchDocument, buildContentSearchDocument, buildProductSearchDocuments } from './documents' +import { isMeilisearchEnabled } from './env' +import { loadSearchProfiles, SEARCH_INDEX_TYPES, type SearchIndexType, type SearchProfile } from './profiles' +import { SEARCH_INDEX_SETTINGS } from './settings' + +export type SearchProfileSyncMode = 'full' | 'normal' + +export type SearchProfileSyncResult = { + deleted: number + indexed: number + mode: SearchProfileSyncMode + profiles: number +} + +export type SearchProfileSyncOptions = { + profileKeys?: string[] +} + +type SearchSyncTargets = Record + +type SearchProfileSyncStateService = { + updateSearchProfiles: (data: Record) => Promise +} + +type DatabaseConnection = { + raw: (query: string, bindings?: unknown[]) => Promise +} + +type ProfileReferenceIds = { + brandIds: Set + categoryIds: Set + categoryProductTitles: Map +} + +const BATCH_SIZE = 500 +const SEARCH_SYNC_LOCK_KEY = 'meilisearch-search-profiles-sync' + +const PRODUCT_FIELDS = [ + 'id', + 'status', + 'title', + 'description', + 'handle', + 'thumbnail', + 'created_at', + 'metadata', + 'categories.id', + 'categories.name', + 'categories.description', + 'categories.handle', + 'brand.id', + 'brand.title', + 'brand.description', + 'brand.handle', + 'sales_channels.id', + 'variants.id', + 'variants.sku', + 'variants.ean', + 'variants.upc', + 'variants.barcode', + 'variants.metadata', + 'variants.prices.amount', + 'variants.prices.currency_code' +] + +const CATEGORY_FIELDS = ['id', 'name', 'description', 'handle', 'is_active', 'parent_category_id'] +const BRAND_FIELDS = ['id', 'title', 'description', 'handle'] + +const asRecord = (value: unknown): Record | undefined => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return + } + + return value as Record +} + +const asRecords = (value: unknown): Record[] => Array.isArray(value) ? value.map((entry) => asRecord(entry)).filter((entry): entry is Record => entry !== undefined) : [] + +const getId = (record: Record): string | undefined => { + const id = record.id + + if (typeof id === 'string' && id.trim()) { + return id + } + + if (typeof id === 'number' && Number.isFinite(id)) { + return String(id) + } + + return +} + +const getRawQueryRows = (result: unknown): Record[] => { + if (Array.isArray(result)) { + return asRecords(result[0] ?? result) + } + + const record = asRecord(result) + + return asRecords(record?.rows) +} + +const toNumber = (value: unknown): number => { + if (typeof value === 'number') { + return value + } + + if (typeof value === 'string') { + return Number.parseFloat(value) + } + + return Number.NaN +} + +const readProductPopularity = async (database: DatabaseConnection, profile: SearchProfile): Promise> => { + const salesChannelClause = profile.salesChannelIds.length > 0 ? 'and o.sales_channel_id = any(?::text[])' : '' + + const result = await database.raw( + 'select oli.product_id, ' + + 'sum(oi.quantity)::float as sold_quantity ' + + 'from order_item oi ' + + 'join order_line_item oli ' + + 'on oli.id = oi.item_id ' + + 'and oli.deleted_at is null ' + + 'join "order" o ' + + 'on o.id = oi.order_id ' + + 'and o.version = oi.version ' + + 'where oi.deleted_at is null ' + + 'and o.deleted_at is null ' + + 'and o.canceled_at is null ' + + 'and o.is_draft_order = false ' + + 'and oli.product_id is not null ' + + salesChannelClause + + ' group by oli.product_id', + + profile.salesChannelIds.length > 0 ? [profile.salesChannelIds] : []) + + const popularity = new Map() + + for (const row of getRawQueryRows(result)) { + const productId = row.product_id + const soldQuantity = toNumber(row.sold_quantity) + + if (typeof productId === 'string' && Number.isFinite(soldQuantity)) { + popularity.set(productId, Math.max(0, soldQuantity)) + } + } + + return popularity +} + +const productBelongsToProfile = (document: Record, profile: SearchProfile): boolean => { + if (profile.availability === 'in-stock' && document.facet_in_stock !== true) { + return false + } + + if (profile.salesChannelIds.length === 0) { + return true + } + + const productSalesChannelIds = Array.isArray(document.facet_sales_channel_ids) ? document.facet_sales_channel_ids.filter((value): value is string => typeof value === 'string') : [] + + return profile.salesChannelIds.some((id) => productSalesChannelIds.includes(id)) +} + +const collectCategoryReferences = (product: Record, references: ProfileReferenceIds) => { + for (const category of asRecords(product.categories)) { + const id = getId(category) + + if (id) { + references.categoryIds.add(id) + + const title = typeof product.title === 'string' ? product.title.trim() : undefined + + if (title) { + const titles = references.categoryProductTitles.get(id) ?? [] + + if (!titles.includes(title) && titles.join(' ').length < 10000) { + titles.push(title) + references.categoryProductTitles.set(id, titles) + } + } + } + } +} + +const collectBrandReferences = (product: Record, references: ProfileReferenceIds) => { + const brands = Array.isArray(product.brand) ? asRecords(product.brand) : [asRecord(product.brand)].filter((entry): entry is Record => entry !== undefined) + + for (const brand of brands) { + const id = getId(brand) + + if (id) { + references.brandIds.add(id) + } + } +} + +const collectProductReferences = (product: Record, references: ProfileReferenceIds) => { + collectCategoryReferences(product, references) + collectBrandReferences(product, references) +} + +const fetchGraphBatch = async ( + query: Query, + + options: { + entity: string + fields: string[] + filters?: Record + offset: number + } +): Promise[]> => { + const { data } = await query.graph({ entity: options.entity, fields: options.fields, filters: options.filters, pagination: { take: BATCH_SIZE, skip: options.offset } }) + + return asRecords(data) +} + +const normalizeLocale = (value: string): string => value.trim().toLowerCase().replaceAll('_', '-').split('-')[0] ?? '' + +const applyLocalizedTranslations = async (query: Query, records: Record[], locale: string): Promise[]> => { + const ids = records.map(getId).filter((id): id is string => id !== undefined) + + if (ids.length === 0 || locale === 'default') { + return records + } + + let data: unknown[] + + try { + const result = await query.graph({ entity: 'translation', fields: ['reference_id', 'locale_code', 'translations'], filters: { reference_id: ids } }) + + data = result.data + } catch { + return records + } + + const translationsById = new Map>() + + for (const row of asRecords(data)) { + if (typeof row.reference_id !== 'string' || typeof row.locale_code !== 'string' || normalizeLocale(row.locale_code) !== normalizeLocale(locale)) { + continue + } + + const translations = asRecord(row.translations) + + if (translations) { + translationsById.set(row.reference_id, translations) + } + } + + return records.map((record) => { + const id = getId(record) + const translations = id ? translationsById.get(id) : undefined + + return translations ? { ...record, ...translations } : record + }) +} + +const deleteStaleDocuments = async (client: MeilisearchAdminClient, index: string, currentIds: Set): Promise => { + const staleIds = (await client.getDocumentIds(index)).filter((id) => !currentIds.has(id)) + + for (let offset = 0; offset < staleIds.length; offset += BATCH_SIZE) { + await client.deleteDocuments(index, staleIds.slice(offset, offset + BATCH_SIZE)) + } + + return staleIds.length +} + +const indexProductDocuments = async (options: { + client: MeilisearchAdminClient + index: string + popularityByProductId: Map + profile: SearchProfile + query: Query +}): Promise<{ + ids: Set + indexed: number + references: ProfileReferenceIds +}> => { + const { client, index, popularityByProductId, profile, query } = options + const ids = new Set() + + const references: ProfileReferenceIds = { + brandIds: new Set(), + categoryIds: new Set(), + categoryProductTitles: new Map() + } + + let offset = 0 + + while (true) { + const records = await fetchGraphBatch(query, { entity: 'product', fields: PRODUCT_FIELDS, filters: { status: 'published' }, offset: offset }) + + if (records.length === 0) { + break + } + + const localizedRecords = await applyLocalizedTranslations(query, records, profile.locale) + + const documents = localizedRecords + .flatMap((record) => buildProductSearchDocuments(record, { popularity: popularityByProductId.get(getId(record) ?? '') })) + .filter((document) => productBelongsToProfile(document, profile)) + + for (const document of documents) { + const id = getId(document) + + if (id) { + ids.add(id) + } + + collectProductReferences(document, references) + } + + await client.addDocuments(index, documents) + + offset += records.length + + if (records.length < BATCH_SIZE) { + break + } + } + + return { ids: ids, indexed: ids.size, references: references } +} + +const indexReferencedEntities = async ( + query: Query, + client: MeilisearchAdminClient, + + options: { + entity: 'brand' | 'product_category' + fields: string[] + ids: Set + index: string + locale: string + transform: (document: Record) => Record + } +): Promise> => { + const currentIds = new Set() + const ids = [...options.ids] + + for (let offset = 0; offset < ids.length; offset += BATCH_SIZE) { + const batchIds = ids.slice(offset, offset + BATCH_SIZE) + const { data } = await query.graph({ entity: options.entity, fields: options.fields, filters: { id: { $in: batchIds }, ...(options.entity === 'product_category' ? { is_active: true } : {}) } }) + const localizedRecords = await applyLocalizedTranslations(query, asRecords(data), options.locale) + const documents = localizedRecords.map(options.transform) + + for (const document of documents) { + const id = getId(document) + + if (id) { + currentIds.add(id) + } + } + + await client.addDocuments(options.index, documents) + } + + return currentIds +} + +const resolvePayloadService = (container: MedusaContainer): PayloadModuleService | null => { + try { + return container.resolve(PAYLOAD_MODULE) + } catch { + return null + } +} + +const indexContentDocuments = async ( + payload: PayloadModuleService | null, + client: MeilisearchAdminClient, + profile: SearchProfile, + index: string +): Promise> => { + const currentIds = new Set() + + if (!payload) { + return currentIds + } + + const indexCollection = async (type: 'article' | 'page') => { + let page = 1 + + while (true) { + const result = type === 'article' ? await payload.listPublishedArticles({ limit: BATCH_SIZE, locale: profile.locale, page: page }) : await payload.listPublishedPages({ limit: BATCH_SIZE, locale: profile.locale, page: page }) + const documents = result.docs.map((document) => buildContentSearchDocument(document as Record, type, profile.locale)) + + for (const document of documents) { + const id = getId(document) + + if (id) { + currentIds.add(id) + } + } + + await client.addDocuments(index, documents) + + if (!result.hasNextPage) { + break + } + + page += 1 + } + } + + await indexCollection('page') + await indexCollection('article') + + return currentIds +} + +const createTargets = (profile: SearchProfile, mode: SearchProfileSyncMode): SearchSyncTargets => { + if (mode === 'normal') { + return profile.indexes + } + + const suffix = 'build_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8) + + return Object.fromEntries(Object.entries(profile.indexes).map(([type, index]) => [type, index + '__' + suffix])) as SearchSyncTargets +} + +const prepareTargets = async (client: MeilisearchAdminClient, targets: SearchSyncTargets) => { + for (const type of Object.keys(targets) as SearchIndexType[]) { + const index = targets[type] + + await client.ensureIndex(index) + await client.updateSettings(index, SEARCH_INDEX_SETTINGS[type] as Record) + } +} + +const finalizeFullSync = async (client: MeilisearchAdminClient, profile: SearchProfile, targets: SearchSyncTargets) => { + const completionMarkerId = 'search_build_marker_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8) + + for (const type of SEARCH_INDEX_TYPES) { + await client.ensureIndex(profile.indexes[type]) + } + + await client.addDocuments(targets.content, [{ id: completionMarkerId }]) + await client.swapIndexPairs(SEARCH_INDEX_TYPES.map((type) => ({ first: profile.indexes[type], second: targets[type] })), { index: profile.indexes.content, documentId: completionMarkerId }) + await client.deleteDocuments(profile.indexes.content, [completionMarkerId]) + + for (const type of SEARCH_INDEX_TYPES) { + await client.deleteIndex(targets[type]) + } +} + +const cleanupBuildTargets = async (client: MeilisearchAdminClient, targets: SearchSyncTargets, logger: Logger) => { + for (const index of Object.values(targets)) { + try { + await client.deleteIndex(index) + } catch (error) { + logger.warn('Unable to clean temporary Meilisearch index ' + index + ': ' + (error instanceof Error ? error.message : String(error))) + } + } +} + +const syncProfile = async (options: { + client: MeilisearchAdminClient + container: MedusaContainer + logger: Logger + mode: SearchProfileSyncMode + profile: SearchProfile +}): Promise<{ deleted: number; indexed: number }> => { + const { client, container, logger, mode, profile } = options + const query = container.resolve(ContainerRegistrationKeys.QUERY) + const database = container.resolve(ContainerRegistrationKeys.PG_CONNECTION) + const payload = resolvePayloadService(container) + const targets = createTargets(profile, mode) + + let finalized = false + + try { + await prepareTargets(client, targets) + + const popularityByProductId = await readProductPopularity(database, profile) + + const products = await indexProductDocuments({ + query: query, + client: client, + profile: profile, + index: targets.product, + popularityByProductId: popularityByProductId + }) + + const categoryIds = await indexReferencedEntities(query, client, { + entity: 'product_category', + fields: CATEGORY_FIELDS, + ids: products.references.categoryIds, + index: targets.category, + locale: profile.locale, + + transform: (document) => { + const category = buildCategorySearchDocument(document) + const id = getId(category) + + if (!id || profile.strict) { + return category + } + + const productTitles = products.references.categoryProductTitles.get(id) ?? [] + + return { ...category, ...(productTitles.length > 0 ? { product_titles: productTitles.join(' ').slice(0, 10000) } : {}) } + } + }) + + const brandIds = await indexReferencedEntities(query, client, { + entity: 'brand', + fields: BRAND_FIELDS, + ids: products.references.brandIds, + index: targets.brand, + locale: profile.locale, + transform: buildBrandSearchDocument + }) + + const contentIds = await indexContentDocuments(payload, client, profile, targets.content) + + let deleted = 0 + + if (mode === 'normal') { + deleted += await deleteStaleDocuments(client, targets.product, products.ids) + deleted += await deleteStaleDocuments(client, targets.category, categoryIds) + deleted += await deleteStaleDocuments(client, targets.brand, brandIds) + deleted += await deleteStaleDocuments(client, targets.content, contentIds) + } else { + await finalizeFullSync(client, profile, targets) + + finalized = true + } + + const indexed = products.indexed + categoryIds.size + brandIds.size + contentIds.size + + logger.info('Meilisearch profile ' + profile.key + ' synchronized: mode=' + mode + ', indexed=' + indexed + ', deleted=' + deleted) + + return { deleted, indexed } + } finally { + if (mode === 'full' && !finalized) { + await cleanupBuildTargets(client, targets, logger) + } + } +} + +const updateProfileSyncState = async (options: { container: MedusaContainer; logger: Logger; profile: SearchProfile; state: Record }) => { + if (!options.profile.id) { + return + } + + try { + const service = options.container.resolve(SEARCH_PROFILE_MODULE) + const update = { id: options.profile.id, ...options.state } + + await service.updateSearchProfiles(update) + } catch (error) { + options.logger.warn('Unable to record Meilisearch sync state for ' + options.profile.key + ': ' + (error instanceof Error ? error.message : String(error))) + } +} + +const syncProfileWithStatus = async (options: { + client: MeilisearchAdminClient + container: MedusaContainer + logger: Logger + mode: SearchProfileSyncMode + profile: SearchProfile +}): Promise<{ deleted: number; indexed: number }> => { + const runningState = { ...options, state: { last_sync_status: 'running', last_sync_mode: options.mode, last_sync_started_at: new Date(), last_sync_error: null } } + + await updateProfileSyncState(runningState) + + try { + const result = await syncProfile(options) + + const succeededState = { + ...options, + + state: { + last_sync_status: 'succeeded', + last_sync_mode: options.mode, + last_synced_at: new Date(), + last_sync_error: null, + last_indexed_count: result.indexed, + last_deleted_count: result.deleted + } + } + + await updateProfileSyncState(succeededState) + + return result + } catch (error) { + const failedState = { ...options, state: { last_sync_status: 'failed', last_sync_mode: options.mode, last_sync_error: error instanceof Error ? error.message : String(error) } } + + await updateProfileSyncState(failedState) + + throw error + } +} + +const synchronizeUnlocked = async ( + container: MedusaContainer, + mode: SearchProfileSyncMode, + options?: SearchProfileSyncOptions +): Promise => { + const logger = container.resolve(ContainerRegistrationKeys.LOGGER) + + if (!isMeilisearchEnabled()) { + logger.info('Skipping search profile sync because Meilisearch is disabled') + + return { deleted: 0, indexed: 0, mode: mode, profiles: 0 } + } + + const configuredProfiles = await loadSearchProfiles(container) + const requestedKeys = options?.profileKeys?.length ? new Set(options.profileKeys) : undefined + const profiles = requestedKeys ? configuredProfiles.filter((profile) => requestedKeys.has(profile.key)) : configuredProfiles + const client = new MeilisearchAdminClient() + + let deleted = 0 + let indexed = 0 + + for (const profile of profiles) { + const result = await syncProfileWithStatus({ + client: client, + container: container, + logger: logger, + mode: mode, + profile: profile + }) + + deleted += result.deleted + indexed += result.indexed + } + + return { deleted: deleted, indexed: indexed, mode: mode, profiles: profiles.length } +} + +export const synchronizeSearchProfiles = async ( + container: MedusaContainer, + mode: SearchProfileSyncMode, + options?: SearchProfileSyncOptions +): Promise => { + const logger = container.resolve(ContainerRegistrationKeys.LOGGER) + const locking = container.resolve(Modules.LOCKING) + + try { + let result: SearchProfileSyncResult = { + deleted: 0, + indexed: 0, + mode, + profiles: 0 + } + + await locking.execute(SEARCH_SYNC_LOCK_KEY, async () => { + result = await synchronizeUnlocked(container, mode, options) + }, { timeout: 1 }) + + return result + } catch (error) { + if (error instanceof Error && error.message.includes('Timed-out')) { + logger.info('Skipping ' + mode + ' Meilisearch sync because another instance holds the lock') + + return { deleted: 0, indexed: 0, mode: mode, profiles: 0 } + } + + throw error + } +} diff --git a/apps/medusa-be/src/modules/payload/service.ts b/apps/medusa-be/src/modules/payload/service.ts index 05029163d4..75df083d2e 100644 --- a/apps/medusa-be/src/modules/payload/service.ts +++ b/apps/medusa-be/src/modules/payload/service.ts @@ -385,6 +385,37 @@ export default class PayloadModuleService extends MedusaService({}) { ) } + /** + * List published public pages for search indexing. + */ + async listPublishedPages(options?: { + limit?: number + locale?: string + page?: number + }): Promise> { + const queryString = this.buildQuery({ + where: { + status: { equals: STATUS_PUBLISHED }, + visibility: { equals: "public" }, + }, + limit: options?.limit ?? 100, + page: options?.page ?? 1, + locale: options?.locale, + }) + + return this.makeRequest>( + "GET", + `/${PAGES}${queryString}`, + undefined, + { + schema: CmsPagesBulkResultSchema, + headers: { + [RETURN_HTML_HEADER]: "true", + }, + } + ) + } + /** * List page categories and their pages, optionally filtered by locale/slug. */ @@ -460,6 +491,36 @@ export default class PayloadModuleService extends MedusaService({}) { ) } + /** + * List published articles for search indexing. + */ + async listPublishedArticles(options?: { + limit?: number + locale?: string + page?: number + }): Promise> { + const queryString = this.buildQuery({ + where: { + status: { equals: STATUS_PUBLISHED }, + }, + limit: options?.limit ?? 100, + page: options?.page ?? 1, + locale: options?.locale, + }) + + return this.makeRequest>( + "GET", + `/${ARTICLES}${queryString}`, + undefined, + { + schema: CmsArticlesBulkResultSchema, + headers: { + [RETURN_HTML_HEADER]: "true", + }, + } + ) + } + /** * List article categories and their articles, optionally filtered by locale/slug. */ diff --git a/apps/medusa-be/src/modules/search-profile/index.ts b/apps/medusa-be/src/modules/search-profile/index.ts new file mode 100644 index 0000000000..c213b15061 --- /dev/null +++ b/apps/medusa-be/src/modules/search-profile/index.ts @@ -0,0 +1,12 @@ +import { Module } from '@medusajs/framework/utils' +import bootstrapSearchProfiles from './loaders/bootstrap-search-profiles' +import SearchProfileModuleService from './service' + +export const SEARCH_PROFILE_MODULE = 'search_profile' +export default Module(SEARCH_PROFILE_MODULE, { service: SearchProfileModuleService, loaders: [bootstrapSearchProfiles] }) +export type { default as SearchProfileModuleService } from './service' + +export type { + SearchProfileDTO, + SearchProfileWriteInput +} from './types' diff --git a/apps/medusa-be/src/modules/search-profile/loaders/bootstrap-search-profiles.ts b/apps/medusa-be/src/modules/search-profile/loaders/bootstrap-search-profiles.ts new file mode 100644 index 0000000000..7bb679a6c6 --- /dev/null +++ b/apps/medusa-be/src/modules/search-profile/loaders/bootstrap-search-profiles.ts @@ -0,0 +1,65 @@ +import type { LoaderOptions } from '@medusajs/framework/types' +import { ContainerRegistrationKeys } from '@medusajs/framework/utils' +import { readSearchProfiles, type SearchProfile } from '../../meilisearch/profiles' + +type InternalSearchProfileService = { + create: (data: Record) => Promise + listAndCount: (filter: Record) => Promise<[unknown[], number]> +} + +const toBootstrapRecord = (profile: SearchProfile) => { + const automaticRankingScore = profile.strict ? 0.98 : 0.55 + + return { + key: profile.key, + shop: profile.shop, + domain: profile.domain, + locale: profile.locale, + sales_channel_ids: profile.salesChannelIds, + strict: profile.strict, + separate_variant_results: profile.separateVariantResults, + minimum_ranking_score: profile.minimumRankingScore === automaticRankingScore ? null : profile.minimumRankingScore, + availability: profile.availability, + autocomplete_product_limit: profile.limits.autocomplete.product, + autocomplete_category_limit: profile.limits.autocomplete.category, + autocomplete_brand_limit: profile.limits.autocomplete.brand, + autocomplete_content_limit: profile.limits.autocomplete.content, + full_search_limit: profile.limits.fullSearch, + max_results_per_page: profile.limits.page, + popular_limit: profile.limits.popular + } +} + +export default async function bootstrapSearchProfiles({ container }: LoaderOptions) { + const logger = container.resolve(ContainerRegistrationKeys.LOGGER) + const service = container.resolve('searchProfileService') + const [, count] = await service.listAndCount({}) + + if (count > 0) { + return + } + + if (!process.env.MEILISEARCH_SEARCH_PROFILES?.trim()) { + logger.info('Meilisearch: no search profiles configured; create one in Medusa Admin') + + return + } + + const profiles = readSearchProfiles() + + for (const profile of profiles) { + try { + await service.create(toBootstrapRecord(profile)) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + + if (message.includes('unique constraint') || message.includes('duplicate key')) { + continue + } + + throw error + } + } + + logger.info('Meilisearch: bootstrapped ' + profiles.length + ' search profile(s) into Medusa') +} diff --git a/apps/medusa-be/src/modules/search-profile/migrations/.snapshot-search-profile.json b/apps/medusa-be/src/modules/search-profile/migrations/.snapshot-search-profile.json new file mode 100644 index 0000000000..2e52bb6c2d --- /dev/null +++ b/apps/medusa-be/src/modules/search-profile/migrations/.snapshot-search-profile.json @@ -0,0 +1,337 @@ +{ + "namespaces": ["public"], + "name": "public", + "tables": [ + { + "columns": { + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "mappedType": "text" + }, + "key": { + "name": "key", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "shop": { + "name": "shop", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "domain": { + "name": "domain", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "locale": { + "name": "locale", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "sales_channel_ids": { + "name": "sales_channel_ids", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "json" + }, + "strict": { + "name": "strict", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "false", + "mappedType": "boolean" + }, + "separate_variant_results": { + "name": "separate_variant_results", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "false", + "mappedType": "boolean" + }, + "minimum_ranking_score": { + "name": "minimum_ranking_score", + "type": "float4", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "precision": 24, + "mappedType": "float" + }, + "availability": { + "name": "availability", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "'all'", + "mappedType": "text" + }, + "autocomplete_product_limit": { + "name": "autocomplete_product_limit", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "6", + "mappedType": "integer" + }, + "autocomplete_category_limit": { + "name": "autocomplete_category_limit", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "3", + "mappedType": "integer" + }, + "autocomplete_brand_limit": { + "name": "autocomplete_brand_limit", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "3", + "mappedType": "integer" + }, + "autocomplete_content_limit": { + "name": "autocomplete_content_limit", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "3", + "mappedType": "integer" + }, + "full_search_limit": { + "name": "full_search_limit", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "500", + "mappedType": "integer" + }, + "max_results_per_page": { + "name": "max_results_per_page", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "100", + "mappedType": "integer" + }, + "popular_limit": { + "name": "popular_limit", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "12", + "mappedType": "integer" + }, + "last_sync_status": { + "name": "last_sync_status", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "'never'", + "mappedType": "text" + }, + "last_sync_mode": { + "name": "last_sync_mode", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "last_sync_started_at": { + "name": "last_sync_started_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "last_indexed_count": { + "name": "last_indexed_count", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "0", + "mappedType": "integer" + }, + "last_deleted_count": { + "name": "last_deleted_count", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "0", + "mappedType": "integer" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + } + }, + "name": "search_profile", + "schema": "public", + "indexes": [ + { + "keyName": "IDX_search_profile_deleted_at", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_search_profile_deleted_at\" ON \"search_profile\" (\"deleted_at\") WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_search_profile_key_unique", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_search_profile_key_unique\" ON \"search_profile\" (\"key\") WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_search_profile_scope_unique", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_search_profile_scope_unique\" ON \"search_profile\" (\"shop\", \"domain\", \"locale\") WHERE deleted_at IS NULL" + }, + { + "keyName": "search_profile_pkey", + "columnNames": ["id"], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [ + { + "name": "search_profile_availability_check", + "expression": "availability in ('all', 'in-stock')", + "definition": "check ((availability = any (array['all'::text, 'in-stock'::text])))" + }, + { + "name": "search_profile_minimum_ranking_score_check", + "expression": "minimum_ranking_score is null or (minimum_ranking_score >= 0 and minimum_ranking_score <= 1)", + "definition": "check (((minimum_ranking_score is null) or ((minimum_ranking_score >= (0)::double precision) and (minimum_ranking_score <= (1)::double precision))))" + }, + { + "name": "search_profile_sync_status_check", + "expression": "last_sync_status in ('never', 'running', 'succeeded', 'failed')", + "definition": "check ((last_sync_status = any (array['never'::text, 'running'::text, 'succeeded'::text, 'failed'::text])))" + }, + { + "name": "search_profile_sync_mode_check", + "expression": "last_sync_mode is null or last_sync_mode in ('normal', 'full')", + "definition": "check (((last_sync_mode is null) or (last_sync_mode = any (array['normal'::text, 'full'::text]))))" + } + ], + "foreignKeys": {}, + "nativeEnums": {} + } + ] +} diff --git a/apps/medusa-be/src/modules/search-profile/migrations/Migration20260730173000.ts b/apps/medusa-be/src/modules/search-profile/migrations/Migration20260730173000.ts new file mode 100644 index 0000000000..01412e93c0 --- /dev/null +++ b/apps/medusa-be/src/modules/search-profile/migrations/Migration20260730173000.ts @@ -0,0 +1,18 @@ +import { Migration } from '@medusajs/framework/mikro-orm/migrations' + +export class Migration20260730173000 extends Migration { + override async up(): Promise { + this.addSql('create table if not exists "search_profile" ("id" text not null, "key" text not null, "shop" text not null, "domain" text not null, "locale" text not null, "sales_channel_ids" jsonb not null, "strict" boolean not null default false, "separate_variant_results" boolean not null default false, "minimum_ranking_score" real null, "availability" text not null default \'all\', "autocomplete_product_limit" integer not null default 6, "autocomplete_category_limit" integer not null default 3, "autocomplete_brand_limit" integer not null default 3, "autocomplete_content_limit" integer not null default 3, "full_search_limit" integer not null default 500, "max_results_per_page" integer not null default 100, "popular_limit" integer not null default 12, "last_sync_status" text not null default \'never\', "last_sync_mode" text null, "last_sync_started_at" timestamptz null, "last_synced_at" timestamptz null, "last_sync_error" text null, "last_indexed_count" integer not null default 0, "last_deleted_count" integer not null default 0, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "search_profile_pkey" primary key ("id"));') + this.addSql('create index if not exists "IDX_search_profile_deleted_at" on "search_profile" ("deleted_at") where deleted_at is null;') + this.addSql('create unique index if not exists "IDX_search_profile_key_unique" on "search_profile" ("key") where deleted_at is null;') + this.addSql('create unique index if not exists "IDX_search_profile_scope_unique" on "search_profile" ("shop", "domain", "locale") where deleted_at is null;') + this.addSql('alter table if exists "search_profile" add constraint "search_profile_availability_check" check ("availability" in (\'all\', \'in-stock\'));') + this.addSql('alter table if exists "search_profile" add constraint "search_profile_minimum_ranking_score_check" check ("minimum_ranking_score" is null or ("minimum_ranking_score" >= 0 and "minimum_ranking_score" <= 1));') + this.addSql('alter table if exists "search_profile" add constraint "search_profile_sync_status_check" check ("last_sync_status" in (\'never\', \'running\', \'succeeded\', \'failed\'));') + this.addSql('alter table if exists "search_profile" add constraint "search_profile_sync_mode_check" check ("last_sync_mode" is null or "last_sync_mode" in (\'normal\', \'full\'));') + } + + override async down(): Promise { + this.addSql('drop table if exists "search_profile" cascade;') + } +} diff --git a/apps/medusa-be/src/modules/search-profile/models/search-profile.ts b/apps/medusa-be/src/modules/search-profile/models/search-profile.ts new file mode 100644 index 0000000000..8064f48dc6 --- /dev/null +++ b/apps/medusa-be/src/modules/search-profile/models/search-profile.ts @@ -0,0 +1,63 @@ +import { model } from '@medusajs/framework/utils' + +const SearchProfile = model + .define('search_profile', { + id: model.id().primaryKey(), + key: model.text().searchable(), + shop: model.text().searchable(), + domain: model.text().searchable(), + locale: model.text().searchable(), + sales_channel_ids: model.json(), + strict: model.boolean().default(false), + separate_variant_results: model.boolean().default(false), + minimum_ranking_score: model.float().nullable(), + availability: model.text().default('all'), + autocomplete_product_limit: model.number().default(6), + autocomplete_category_limit: model.number().default(3), + autocomplete_brand_limit: model.number().default(3), + autocomplete_content_limit: model.number().default(3), + full_search_limit: model.number().default(500), + max_results_per_page: model.number().default(100), + popular_limit: model.number().default(12), + last_sync_status: model.text().default('never'), + last_sync_mode: model.text().nullable(), + last_sync_started_at: model.dateTime().nullable(), + last_synced_at: model.dateTime().nullable(), + last_sync_error: model.text().nullable(), + last_indexed_count: model.number().default(0), + last_deleted_count: model.number().default(0) + }) + .indexes([ + { + name: 'IDX_search_profile_key_unique', + on: ['key'], + unique: true, + where: 'deleted_at IS NULL' + }, + { + name: 'IDX_search_profile_scope_unique', + on: ['shop', 'domain', 'locale'], + unique: true, + where: 'deleted_at IS NULL' + } + ]) + .checks([ + { + name: 'search_profile_availability_check', + expression: (columns) => columns.availability + ' in (\'all\', \'in-stock\')' + }, + { + name: 'search_profile_minimum_ranking_score_check', + expression: (columns) => columns.minimum_ranking_score + ' is null or (' + columns.minimum_ranking_score + ' >= 0 and ' + columns.minimum_ranking_score + ' <= 1)' + }, + { + name: 'search_profile_sync_status_check', + expression: (columns) => columns.last_sync_status + ' in (\'never\', \'running\', \'succeeded\', \'failed\')' + }, + { + name: 'search_profile_sync_mode_check', + expression: (columns) => columns.last_sync_mode + ' is null or ' + columns.last_sync_mode + ' in (\'normal\', \'full\')' + } + ]) + +export default SearchProfile diff --git a/apps/medusa-be/src/modules/search-profile/service.ts b/apps/medusa-be/src/modules/search-profile/service.ts new file mode 100644 index 0000000000..7f08d09f0c --- /dev/null +++ b/apps/medusa-be/src/modules/search-profile/service.ts @@ -0,0 +1,59 @@ +import type { ICachingModuleService } from '@medusajs/framework/types' +import { MedusaService, Modules } from '@medusajs/framework/utils' +import { safeResolve } from '../../utils/safe-resolve' +import SearchProfile from './models/search-profile' +import type { SearchProfileDTO } from './types' + +const RUNTIME_PROFILES_CACHE_KEY = 'search-profile:runtime:enabled' +const RUNTIME_PROFILES_CACHE_TTL_SECONDS = 30 + +type InjectedDependencies = { + [Modules.CACHING]?: ICachingModuleService +} + +class SearchProfileModuleService extends MedusaService({ SearchProfile }) { + private readonly cacheService_: ICachingModuleService | null + + constructor(container: InjectedDependencies) { + super(container) + + this.cacheService_ = safeResolve(container, Modules.CACHING) + } + + async listConfiguredProfiles(options?: { enabledOnly?: boolean }): Promise { + const profiles = await this.listSearchProfiles({}, { order: { shop: 'ASC', domain: 'ASC', locale: 'ASC', key: 'ASC' } }) + const configuredProfiles = profiles as unknown as SearchProfileDTO[] + + return options?.enabledOnly ? configuredProfiles.filter((profile) => Array.isArray(profile.sales_channel_ids) && profile.sales_channel_ids.length > 0) : configuredProfiles + } + + async listRuntimeProfiles(): Promise { + if (this.cacheService_) { + const cached = await this.cacheService_.get({ key: RUNTIME_PROFILES_CACHE_KEY }) + + if (Array.isArray(cached)) { + return cached as SearchProfileDTO[] + } + } + + const profiles = await this.listConfiguredProfiles({ enabledOnly: true }) + + if (this.cacheService_) { + await this.cacheService_.set({ + key: RUNTIME_PROFILES_CACHE_KEY, + data: profiles, + tags: [], + ttl: RUNTIME_PROFILES_CACHE_TTL_SECONDS, + options: { autoInvalidate: false } + }) + } + + return profiles + } + + async invalidateRuntimeProfileCache(): Promise { + await this.cacheService_?.clear({ key: RUNTIME_PROFILES_CACHE_KEY }) + } +} + +export default SearchProfileModuleService diff --git a/apps/medusa-be/src/modules/search-profile/types.ts b/apps/medusa-be/src/modules/search-profile/types.ts new file mode 100644 index 0000000000..a1c4b90c4d --- /dev/null +++ b/apps/medusa-be/src/modules/search-profile/types.ts @@ -0,0 +1,44 @@ +export type SearchProfileDTO = { + id: string + key: string + shop: string + domain: string + locale: string + sales_channel_ids: string[] + strict: boolean + separate_variant_results: boolean + minimum_ranking_score: number | null + availability: 'all' | 'in-stock' + autocomplete_product_limit: number + autocomplete_category_limit: number + autocomplete_brand_limit: number + autocomplete_content_limit: number + full_search_limit: number + max_results_per_page: number + popular_limit: number + last_sync_status: 'never' | 'running' | 'succeeded' | 'failed' + last_sync_mode: 'normal' | 'full' | null + last_sync_started_at: Date | string | null + last_synced_at: Date | string | null + last_sync_error: string | null + last_indexed_count: number + last_deleted_count: number + created_at: Date | string + updated_at: Date | string + deleted_at?: Date | string | null +} + +export type SearchProfileWriteInput = Omit< + SearchProfileDTO, + | 'id' + | 'created_at' + | 'updated_at' + | 'deleted_at' + | 'last_sync_status' + | 'last_sync_mode' + | 'last_sync_started_at' + | 'last_synced_at' + | 'last_sync_error' + | 'last_indexed_count' + | 'last_deleted_count' +> diff --git a/apps/medusa-be/src/modules/storefront-text/definitions/search.ts b/apps/medusa-be/src/modules/storefront-text/definitions/search.ts index b981815434..f5b061572f 100644 --- a/apps/medusa-be/src/modules/storefront-text/definitions/search.ts +++ b/apps/medusa-be/src/modules/storefront-text/definitions/search.ts @@ -51,6 +51,11 @@ export const STOREFRONT_SEARCH_TEXT_DEFINITIONS = [ key: "search.autocomplete.sections.brands", namespace: "search", }, + { + description: "Nadpis sekce obsahu v návrzích vyhledávání.", + key: "search.autocomplete.sections.content", + namespace: "search", + }, { description: "Typ návrhu pro kategorii.", key: "search.autocomplete.types.category", diff --git a/apps/medusa-be/src/modules/storefront-text/messages/cs-CZ.json b/apps/medusa-be/src/modules/storefront-text/messages/cs-CZ.json index 1e6f856f82..351b4690af 100644 --- a/apps/medusa-be/src/modules/storefront-text/messages/cs-CZ.json +++ b/apps/medusa-be/src/modules/storefront-text/messages/cs-CZ.json @@ -725,7 +725,8 @@ "sections": { "products": "Produkty", "categories": "Kategorie", - "brands": "Značky" + "brands": "Značky", + "content": "Obsah" }, "types": { "category": "Kategorie", diff --git a/apps/medusa-be/src/modules/storefront-text/messages/hu-HU.json b/apps/medusa-be/src/modules/storefront-text/messages/hu-HU.json index 7af32c09ab..c2aa51bbc1 100644 --- a/apps/medusa-be/src/modules/storefront-text/messages/hu-HU.json +++ b/apps/medusa-be/src/modules/storefront-text/messages/hu-HU.json @@ -725,7 +725,8 @@ "sections": { "products": "Termékek", "categories": "Kategóriák", - "brands": "Márkák" + "brands": "Márkák", + "content": "Tartalom" }, "types": { "category": "Kategória", diff --git a/apps/medusa-be/src/modules/storefront-text/messages/ro-RO.json b/apps/medusa-be/src/modules/storefront-text/messages/ro-RO.json index e04794c9a7..2011f0b86e 100644 --- a/apps/medusa-be/src/modules/storefront-text/messages/ro-RO.json +++ b/apps/medusa-be/src/modules/storefront-text/messages/ro-RO.json @@ -725,7 +725,8 @@ "sections": { "products": "Produse", "categories": "Categorii", - "brands": "Mărci" + "brands": "Mărci", + "content": "Conținut" }, "types": { "category": "Categorie", diff --git a/apps/medusa-be/src/modules/storefront-text/messages/sk-SK.json b/apps/medusa-be/src/modules/storefront-text/messages/sk-SK.json index a540b05897..89b53a30c4 100644 --- a/apps/medusa-be/src/modules/storefront-text/messages/sk-SK.json +++ b/apps/medusa-be/src/modules/storefront-text/messages/sk-SK.json @@ -725,7 +725,8 @@ "sections": { "products": "Produkty", "categories": "Kategórie", - "brands": "Značky" + "brands": "Značky", + "content": "Obsah" }, "types": { "category": "Kategória", diff --git a/apps/medusa-be/src/scripts/search-index.ts b/apps/medusa-be/src/scripts/search-index.ts index d62ed56588..aa8251a4ee 100644 --- a/apps/medusa-be/src/scripts/search-index.ts +++ b/apps/medusa-be/src/scripts/search-index.ts @@ -1,295 +1,20 @@ -import type { ExecArgs, Logger, Query } from "@medusajs/framework/types" +import type { ExecArgs, Logger } from "@medusajs/framework/types" import { ContainerRegistrationKeys } from "@medusajs/framework/utils" -import type { MeiliSearchService } from "@rokmohar/medusa-plugin-meilisearch" -import { isMeilisearchEnabled } from "../modules/meilisearch/env" - -const BATCH_SIZE = 1000 - -type SyncEntityConfig = { - entity: "product" | "product_category" | "brand" - entityType: "products" | "categories" | "brands" - filters?: Record -} - -type SyncEntityServices = { - container: ExecArgs["container"] - logger: Logger - meilisearchIndexService: MeiliSearchService - queryService: Query -} - -type SyncEntityContext = SyncEntityServices & { - config: SyncEntityConfig - fields: string[] - indexes: string[] -} - -type SyncEntityResult = { - indexed: number - deleted: number -} - -const SYNC_ENTITIES: SyncEntityConfig[] = [ - { - entity: "product", - entityType: "products", - filters: { - status: "published", - }, - }, - { - entity: "product_category", - entityType: "categories", - filters: { - is_active: true, - }, - }, - { - entity: "brand", - entityType: "brands", - }, -] - -const resolveRecordId = (record: unknown): string | undefined => { - if (!record || typeof record !== "object" || Array.isArray(record)) { - return - } - - const id = (record as { id?: unknown }).id - if (typeof id === "string" && id.trim()) { - return id - } - if (typeof id === "number" && Number.isFinite(id)) { - return String(id) - } - - return -} - -const EMPTY_SYNC_ENTITY_RESULT: SyncEntityResult = { - indexed: 0, - deleted: 0, -} - -async function fetchEntityBatch( - { config, fields, queryService }: SyncEntityContext, - offset: number -): Promise { - const { data } = await queryService.graph({ - entity: config.entity, - fields, - pagination: { - take: BATCH_SIZE, - skip: offset, - }, - filters: config.filters, - }) - - return Array.isArray(data) ? data : [] -} - -async function indexEntityBatch( - { config, container, indexes, meilisearchIndexService }: SyncEntityContext, - records: unknown[] -) { - await Promise.all( - indexes.map((index) => - meilisearchIndexService.addDocuments(index, records, config.entityType, { - container, - }) - ) - ) -} - -function addIndexedRecordIds(indexedIds: Set, records: unknown[]) { - for (const record of records) { - const id = resolveRecordId(record) - if (id) { - indexedIds.add(id) - } - } -} - -async function indexEntityRecords( - context: SyncEntityContext -): Promise> { - const indexedIds = new Set() - let offset = 0 - - while (true) { - const records = await fetchEntityBatch(context, offset) - if (records.length === 0) { - break - } - - await indexEntityBatch(context, records) - addIndexedRecordIds(indexedIds, records) - - offset += records.length - if (records.length < BATCH_SIZE) { - break - } - } - - return indexedIds -} - -async function collectOrphanedIdsForIndex( - meilisearchIndexService: MeiliSearchService, - index: string, - indexedIds: Set -): Promise> { - const orphanedIds = new Set() - let searchOffset = 0 - - while (true) { - const indexedResult = await meilisearchIndexService.search(index, "", { - paginationOptions: { - offset: searchOffset, - limit: BATCH_SIZE, - }, - additionalOptions: { - attributesToRetrieve: ["id"], - }, - }) - - const hits = Array.isArray(indexedResult.hits) ? indexedResult.hits : [] - if (hits.length === 0) { - break - } - - for (const hit of hits) { - const id = resolveRecordId(hit) - if (id && !indexedIds.has(id)) { - orphanedIds.add(id) - } - } - - searchOffset += hits.length - if (hits.length < BATCH_SIZE) { - break - } - } - - return orphanedIds -} - -async function collectOrphanedIds( - { indexes, meilisearchIndexService }: SyncEntityContext, - indexedIds: Set -): Promise> { - const orphanedIds = new Set() - for (const index of indexes) { - const indexOrphanedIds = await collectOrphanedIdsForIndex( - meilisearchIndexService, - index, - indexedIds - ) - for (const id of indexOrphanedIds) { - orphanedIds.add(id) - } - } - - return orphanedIds -} - -async function deleteOrphanedIds( - { indexes, meilisearchIndexService }: SyncEntityContext, - orphanedIds: Set -) { - const idsToDelete = Array.from(orphanedIds) - for (let cursor = 0; cursor < idsToDelete.length; cursor += BATCH_SIZE) { - const batch = idsToDelete.slice(cursor, cursor + BATCH_SIZE) - await Promise.all( - indexes.map((index) => - meilisearchIndexService.deleteDocuments(index, batch) - ) - ) - } -} - -const syncEntityToMeilisearch = async ({ - config, - container, - logger, - meilisearchIndexService, - queryService, -}: { - config: SyncEntityConfig - container: ExecArgs["container"] - logger: Logger - meilisearchIndexService: MeiliSearchService - queryService: Query -}): Promise => { - const fields = await meilisearchIndexService.getFieldsForType( - config.entityType - ) - const indexes = await meilisearchIndexService.getIndexesByType( - config.entityType - ) - - if (indexes.length === 0) { - logger.info( - `Skipping ${config.entityType} sync because no MeiliSearch indexes are configured for this type` - ) - return EMPTY_SYNC_ENTITY_RESULT - } - - const context: SyncEntityContext = { - config, - container, - fields, - indexes, - logger, - meilisearchIndexService, - queryService, - } - - const indexedIds = await indexEntityRecords(context) - const orphanedIds = await collectOrphanedIds(context, indexedIds) - if (orphanedIds.size > 0) { - await deleteOrphanedIds(context, orphanedIds) - } - - logger.info( - `Synced ${config.entityType}: indexed=${indexedIds.size}, deleted=${orphanedIds.size}` - ) - - return { - indexed: indexedIds.size, - deleted: orphanedIds.size, - } -} +import { synchronizeSearchProfiles } from "../modules/meilisearch/synchronize" export default async function searchIndexScript({ container }: ExecArgs) { const logger = container.resolve(ContainerRegistrationKeys.LOGGER) - - if (!isMeilisearchEnabled()) { - logger.info("Skipping search indexing because Meilisearch is disabled") - return - } - - const queryService = container.resolve(ContainerRegistrationKeys.QUERY) - - const meilisearchIndexService: MeiliSearchService = - container.resolve("meilisearch") - - let totalIndexed = 0 - let totalDeleted = 0 - - for (const config of SYNC_ENTITIES) { - const result = await syncEntityToMeilisearch({ - config, - container, - logger, - meilisearchIndexService, - queryService, - }) - totalIndexed += result.indexed - totalDeleted += result.deleted - } + const mode = process.argv.includes("--full") ? "full" : "normal" + const result = await synchronizeSearchProfiles(container, mode) logger.info( - `MeiliSearch sync complete: indexed=${totalIndexed}, deleted=${totalDeleted}` + "Meilisearch search-profile sync complete: mode=" + + result.mode + + ", profiles=" + + result.profiles + + ", indexed=" + + result.indexed + + ", deleted=" + + result.deleted ) } diff --git a/apps/medusa-be/src/subscribers/meilisearch-sync-brands.ts b/apps/medusa-be/src/subscribers/meilisearch-sync-brands.ts index 585c1077af..6d48d37654 100644 --- a/apps/medusa-be/src/subscribers/meilisearch-sync-brands.ts +++ b/apps/medusa-be/src/subscribers/meilisearch-sync-brands.ts @@ -1,4 +1,5 @@ import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework" +import { synchronizeSearchProfiles } from "../modules/meilisearch/synchronize" import { syncMeilisearchBrandsWorkflow } from "../workflows/meilisearch/workflows/sync-brands" export default async function meilisearchSyncBrandsHandler({ @@ -7,6 +8,7 @@ export default async function meilisearchSyncBrandsHandler({ await syncMeilisearchBrandsWorkflow(container).run({ input: {}, }) + await synchronizeSearchProfiles(container, "normal") } export const config: SubscriberConfig = { diff --git a/apps/medusa-be/src/workflows/meilisearch/steps/__tests__/brand-search-projection.unit.spec.ts b/apps/medusa-be/src/workflows/meilisearch/steps/__tests__/brand-search-projection.unit.spec.ts deleted file mode 100644 index 72d48cb609..0000000000 --- a/apps/medusa-be/src/workflows/meilisearch/steps/__tests__/brand-search-projection.unit.spec.ts +++ /dev/null @@ -1,174 +0,0 @@ -import type { MedusaContainer } from "@medusajs/framework/types" -import { - ContainerRegistrationKeys, - SearchUtils, -} from "@medusajs/framework/utils" -import { afterEach, describe, expect, it, vi } from "vitest" -import { - BRAND_SEARCH_PROJECTION_EVENT_OPTIONS, - buildBrandSearchProjectionEventData, -} from "../../events" -import { reconcileBrandSearchProjection } from "../reconcile-brand-search-projection" -import { resolveBrandSearchProjectionTargets } from "../resolve-brand-search-projection-targets" - -vi.mock("../../../../links/product-brand", () => ({ - ProductBrandLink: { - entryPoint: "product_brand", - }, -})) - -const asContainer = (resolve: (key: string) => unknown): MedusaContainer => - ({ - resolve: vi.fn(resolve), - }) as unknown as MedusaContainer - -describe("Brand search projection", () => { - afterEach(() => { - vi.unstubAllEnvs() - }) - - it("deduplicates event targets and configures retryable delivery", () => { - expect( - buildBrandSearchProjectionEventData({ - brandIds: ["brand_1", "brand_1"], - productIds: ["prod_1", "prod_1"], - }) - ).toEqual({ - brand_ids: ["brand_1"], - product_ids: ["prod_1"], - }) - expect(BRAND_SEARCH_PROJECTION_EVENT_OPTIONS).toEqual({ - attempts: 5, - backoff: { - delay: 1000, - type: "exponential", - }, - }) - }) - - it("expands changed Brands to their currently linked products", async () => { - vi.stubEnv("MEILISEARCH_ENABLED", "1") - const graph = vi.fn().mockResolvedValue({ - data: [{ product_id: "prod_linked" }, { product_id: "prod_explicit" }], - }) - const container = asContainer((key) => - key === ContainerRegistrationKeys.QUERY ? { graph } : undefined - ) - - const targets = await resolveBrandSearchProjectionTargets( - { - brand_ids: ["brand_1", "brand_1"], - product_ids: ["prod_explicit"], - }, - container - ) - - expect(targets).toEqual({ - brand_ids: ["brand_1"], - product_ids: ["prod_explicit", "prod_linked"], - lock_keys: ["brand-search-projection"], - }) - }) - - it("upserts current active documents and deletes stale or non-published targets", async () => { - vi.stubEnv("MEILISEARCH_ENABLED", "1") - const graph = vi - .fn() - .mockResolvedValueOnce({ - data: [{ handle: "active", id: "brand_active", title: "Active" }], - }) - .mockResolvedValueOnce({ - data: [ - { id: "prod_published", status: "published" }, - { id: "prod_draft", status: "draft" }, - ], - }) - const meilisearch = { - addDocuments: vi.fn().mockResolvedValue(undefined), - deleteDocuments: vi.fn().mockResolvedValue(undefined), - getFieldsForType: vi - .fn() - .mockResolvedValueOnce(["id", "title", "handle"]) - .mockResolvedValueOnce(["id", "status", "brand.id"]), - getIndexesByType: vi - .fn() - .mockResolvedValueOnce(["brands"]) - .mockResolvedValueOnce(["products"]), - } - const container = asContainer((key) => { - if (key === ContainerRegistrationKeys.QUERY) { - return { graph } - } - if (key === "meilisearch") { - return meilisearch - } - return - }) - - const result = await reconcileBrandSearchProjection( - { - brand_ids: ["brand_active", "brand_deleted"], - lock_keys: [], - product_ids: ["prod_published", "prod_draft", "prod_missing"], - }, - container - ) - - expect(meilisearch.addDocuments).toHaveBeenNthCalledWith( - 1, - "brands", - [ - { - handle: "/store/brands/active/products", - id: "brand_active", - title: "Active", - }, - ], - "brands", - { container } - ) - expect(meilisearch.deleteDocuments).toHaveBeenNthCalledWith(1, "brands", [ - "brand_deleted", - ]) - expect(meilisearch.addDocuments).toHaveBeenNthCalledWith( - 2, - "products", - [{ id: "prod_published", status: "published" }], - SearchUtils.indexTypes.PRODUCTS, - { container } - ) - expect(meilisearch.deleteDocuments).toHaveBeenNthCalledWith(2, "products", [ - "prod_draft", - "prod_missing", - ]) - expect(result).toEqual({ - brands_deleted: 1, - brands_upserted: 1, - products_deleted: 2, - products_upserted: 1, - }) - }) - - it("does not resolve services when Meilisearch is disabled", async () => { - vi.stubEnv("MEILISEARCH_ENABLED", "0") - const resolve = vi.fn() - const container = asContainer(resolve) - - await expect( - reconcileBrandSearchProjection( - { - brand_ids: ["brand_1"], - lock_keys: [], - product_ids: ["prod_1"], - }, - container - ) - ).resolves.toEqual({ - brands_deleted: 0, - brands_upserted: 0, - products_deleted: 0, - products_upserted: 0, - }) - expect(resolve).not.toHaveBeenCalled() - }) -}) diff --git a/apps/medusa-be/tests/unit/catalog/catalog-products-route.unit.spec.ts b/apps/medusa-be/tests/unit/catalog/catalog-products-route.unit.spec.ts deleted file mode 100644 index 79cff41317..0000000000 --- a/apps/medusa-be/tests/unit/catalog/catalog-products-route.unit.spec.ts +++ /dev/null @@ -1,617 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" - -const { wrapProductsWithTaxPrices } = vi.hoisted(() => ({ - wrapProductsWithTaxPrices: vi.fn().mockResolvedValue(undefined), -})) - -vi.mock("@medusajs/medusa/api/store/products/helpers", () => ({ - wrapProductsWithTaxPrices, -})) - -import { GET } from "../../../src/api/store/catalog/products/route" - -type TestProduct = { - id: string - title: string - status: "published" | "draft" - salesChannelIds: string[] - statusFacets?: string[] - formFacets?: string[] - brandFacets?: string[] - ingredientFacets?: string[] - facetPrice?: number -} - -type GraphConfig = { - entity: string - fields?: string[] - filters?: Record -} - -type MeiliPriceRange = { max?: number; min?: number } - -const FACET_PRICE_MIN_FILTER_REGEX = /\bfacet_price >= ([0-9.]+)\b/ -const FACET_PRICE_MAX_FILTER_REGEX = /\bfacet_price <= ([0-9.]+)\b/ - -const getFilterIds = (value: unknown): string[] => { - if (Array.isArray(value)) { - return value.filter((item): item is string => typeof item === "string") - } - - if (typeof value === "string") { - return [value] - } - - if (value && typeof value === "object" && !Array.isArray(value)) { - const inValue = (value as Record).$in - if (Array.isArray(inValue)) { - return inValue.filter((item): item is string => typeof item === "string") - } - } - - return [] -} - -const toArray = (value: unknown): string[] => { - if (Array.isArray(value)) { - return value.filter((item): item is string => typeof item === "string") - } - - return typeof value === "string" ? [value] : [] -} - -const toFilterExpressions = (filter: unknown): unknown[] => { - if (typeof filter === "string") { - return [filter] - } - - return Array.isArray(filter) ? filter : [] -} - -const getMeiliFilterValues = (filter: unknown, field: string): string[] => { - const expressions = toFilterExpressions(filter) - - if (expressions.length === 0) { - return [] - } - - const values: string[] = [] - const pattern = new RegExp(`${field} = "([^"]+)"`, "g") - for (const expression of expressions) { - if (typeof expression !== "string") { - continue - } - - for (const match of expression.matchAll(pattern)) { - const value = match[1] - if (value) { - values.push(value) - } - } - } - - return values -} - -const getMeiliPriceRange = (filter: unknown): MeiliPriceRange => { - const expressions = toFilterExpressions(filter) - - if (expressions.length === 0) { - return {} - } - - let min: number | undefined - let max: number | undefined - for (const expression of expressions) { - if (typeof expression !== "string") { - continue - } - - const minMatch = expression.match(FACET_PRICE_MIN_FILTER_REGEX) - if (minMatch?.[1]) { - min = Number(minMatch[1]) - } - const maxMatch = expression.match(FACET_PRICE_MAX_FILTER_REGEX) - if (maxMatch?.[1]) { - max = Number(maxMatch[1]) - } - } - - return { min, max } -} - -const productMatchesSearchFilters = ( - product: TestProduct, - statusFilters: string[], - salesChannelFilters: string[], - priceRange: MeiliPriceRange -) => { - const matchesStatus = - statusFilters.length === 0 || statusFilters.includes(product.status) - const matchesSalesChannel = - salesChannelFilters.length === 0 || - product.salesChannelIds.some((id) => salesChannelFilters.includes(id)) - const isAboveMin = - priceRange.min === undefined || - (product.facetPrice !== undefined && product.facetPrice >= priceRange.min) - const isBelowMax = - priceRange.max === undefined || - (product.facetPrice !== undefined && product.facetPrice <= priceRange.max) - - return matchesStatus && matchesSalesChannel && isAboveMin && isBelowMax -} - -const createMockResponse = () => - ({ - status: vi.fn().mockReturnThis(), - json: vi.fn().mockReturnThis(), - }) as any - -const createCatalogHarness = ({ - products, - salesChannelId = "sc_visible", - query = {}, -}: { - products: TestProduct[] - salesChannelId?: string - query?: Record -}) => { - const productById = new Map(products.map((product) => [product.id, product])) - - const meiliSearch = vi.fn( - async ( - _index: string, - _query: string, - options: { - paginationOptions: { - limit: number - offset: number - } - additionalOptions?: { - attributesToRetrieve?: string[] - } - filter?: string | string[] - } - ) => { - const statusFilters = getMeiliFilterValues( - options.filter, - "facet_product_status" - ) - const salesChannelFilters = getMeiliFilterValues( - options.filter, - "facet_sales_channel_ids" - ) - const priceRange = getMeiliPriceRange(options.filter) - const filteredProducts = products.filter((product) => - productMatchesSearchFilters( - product, - statusFilters, - salesChannelFilters, - priceRange - ) - ) - - const hits = filteredProducts - .slice( - options.paginationOptions.offset, - options.paginationOptions.offset + options.paginationOptions.limit - ) - .map((product) => ({ id: product.id })) - - const visiblePrices = filteredProducts - .map((product) => product.facetPrice) - .filter((price): price is number => typeof price === "number") - - const hiddenBrandCount = filteredProducts.filter((product) => - product.brandFacets?.includes("brand-hidden") - ).length - const visibleBrandCount = filteredProducts.filter((product) => - product.brandFacets?.includes("brand-visible") - ).length - const visibleIngredientCount = filteredProducts.filter((product) => - product.ingredientFacets?.includes("ingredient-visible") - ).length - - return { - hits, - estimatedTotalHits: filteredProducts.length, - facetDistribution: { - facet_status: { - action: filteredProducts.filter((product) => - product.statusFacets?.includes("action") - ).length, - "in-stock": filteredProducts.filter((product) => - product.statusFacets?.includes("in-stock") - ).length, - }, - facet_form: {}, - facet_brand: { - ...(hiddenBrandCount > 0 - ? { "brand-hidden": hiddenBrandCount } - : {}), - ...(visibleBrandCount > 0 - ? { "brand-visible": visibleBrandCount } - : {}), - }, - facet_ingredient: { - ...(visibleIngredientCount > 0 - ? { "ingredient-visible": visibleIngredientCount } - : {}), - }, - }, - facetStats: { - facet_price: { - min: visiblePrices.length ? Math.min(...visiblePrices) : undefined, - max: visiblePrices.length ? Math.max(...visiblePrices) : undefined, - }, - }, - } - } - ) - - const queryGraph = vi.fn(async (config: GraphConfig) => { - const filters = config.filters ?? {} - - if (config.entity === "product_sales_channel") { - const productIds = getFilterIds(filters.product_id) - const salesChannelIds = toArray(filters.sales_channel_id) - - return { - data: productIds.flatMap((productId) => { - const product = productById.get(productId) - if (!product) { - return [] - } - - const isLinked = product.salesChannelIds.some((id) => - salesChannelIds.includes(id) - ) - - return isLinked ? [{ product_id: product.id }] : [] - }), - } - } - - if (config.entity === "product") { - const productIds = getFilterIds(filters.id) - const status = filters.status - - return { - data: productIds - .map((productId) => productById.get(productId)) - .filter((product): product is TestProduct => Boolean(product)) - .filter((product) => !status || product.status === status) - .map((product) => ({ - id: product.id, - title: product.title, - handle: product.id, - thumbnail: null, - metadata: {}, - variants: [], - categories: [], - brand: null, - })), - } - } - - return { data: [] } - }) - - const req = { - validatedQuery: { - q: "", - page: 1, - limit: 12, - sort: "recommended", - ...query, - }, - filterableFields: { - sales_channel_id: [salesChannelId], - status: "published", - }, - queryConfig: { - fields: [], - }, - scope: { - resolve: vi.fn((key: string) => { - if (key === "meilisearch") { - return { search: meiliSearch } - } - return { graph: queryGraph } - }), - }, - } as any - - const res = createMockResponse() - - return { - req, - res, - meiliSearch, - queryGraph, - } -} - -const getJsonPayload = (res: ReturnType) => - res.json.mock.calls[0][0] - -describe("GET /store/catalog/products", () => { - const originalMeilisearchEnabled = process.env.MEILISEARCH_ENABLED - - beforeEach(() => { - vi.clearAllMocks() - process.env.MEILISEARCH_ENABLED = "1" - }) - - afterEach(() => { - if (originalMeilisearchEnabled === undefined) { - Reflect.deleteProperty(process.env, "MEILISEARCH_ENABLED") - return - } - - process.env.MEILISEARCH_ENABLED = originalMeilisearchEnabled - }) - - it("returns unavailable without loading Meilisearch when search is disabled", async () => { - process.env.MEILISEARCH_ENABLED = "0" - const { req, res, meiliSearch, queryGraph } = createCatalogHarness({ - products: [ - { - id: "prod_visible", - title: "Visible product", - status: "published", - salesChannelIds: ["sc_visible"], - }, - ], - }) - - await GET(req, res) - - expect(res.status).toHaveBeenCalledWith(503) - expect(res.json).toHaveBeenCalledWith({ - message: "Catalog search is disabled", - }) - expect(req.scope.resolve).not.toHaveBeenCalled() - expect(meiliSearch).not.toHaveBeenCalled() - expect(queryGraph).not.toHaveBeenCalled() - }) - - it("excludes draft products from Meili hits", async () => { - const { req, res } = createCatalogHarness({ - products: [ - { - id: "prod_draft", - title: "Draft product", - status: "draft", - salesChannelIds: ["sc_visible"], - }, - ], - }) - - await GET(req, res) - - expect(getJsonPayload(res).products).toEqual([]) - expect(getJsonPayload(res).count).toBe(0) - }) - - it("excludes products outside the publishable-key sales channel", async () => { - const { req, res } = createCatalogHarness({ - products: [ - { - id: "prod_hidden_channel", - title: "Hidden channel product", - status: "published", - salesChannelIds: ["sc_other"], - }, - ], - }) - - await GET(req, res) - - expect(getJsonPayload(res).products).toEqual([]) - expect(getJsonPayload(res).count).toBe(0) - }) - - it("returns a published product linked to the visible sales channel", async () => { - const { req, res } = createCatalogHarness({ - products: [ - { - id: "prod_visible", - title: "Visible product", - status: "published", - salesChannelIds: ["sc_visible"], - }, - ], - }) - - await GET(req, res) - - expect(getJsonPayload(res).products).toEqual([ - expect.objectContaining({ - id: "prod_visible", - title: "Visible product", - }), - ]) - expect(getJsonPayload(res).count).toBe(1) - expect(wrapProductsWithTaxPrices).toHaveBeenCalledWith( - req, - getJsonPayload(res).products - ) - }) - - it("passes price filters to Meilisearch before applying visibility", async () => { - const { req, res, meiliSearch } = createCatalogHarness({ - products: [ - { - id: "prod_visible", - title: "Visible product", - status: "published", - salesChannelIds: ["sc_visible"], - facetPrice: 15, - }, - ], - query: { - price_min: 10, - price_max: 20, - }, - }) - - await GET(req, res) - - const searchOptions = meiliSearch.mock.calls[0]?.[2] - expect(searchOptions?.filter).toContain("facet_price >= 10") - expect(searchOptions?.filter).toContain("facet_price <= 20") - expect(getJsonPayload(res).products).toHaveLength(1) - }) - - it("paginates and counts only visible products while preserving Meili order", async () => { - const { req, res } = createCatalogHarness({ - products: [ - { - id: "prod_hidden_channel", - title: "Hidden channel product", - status: "published", - salesChannelIds: ["sc_other"], - }, - { - id: "prod_visible_1", - title: "Visible product 1", - status: "published", - salesChannelIds: ["sc_visible"], - }, - { - id: "prod_draft", - title: "Draft product", - status: "draft", - salesChannelIds: ["sc_visible"], - }, - { - id: "prod_visible_2", - title: "Visible product 2", - status: "published", - salesChannelIds: ["sc_visible"], - }, - { - id: "prod_visible_3", - title: "Visible product 3", - status: "published", - salesChannelIds: ["sc_visible"], - }, - ], - query: { - page: 2, - limit: 1, - }, - }) - - await GET(req, res) - - const payload = getJsonPayload(res) - expect( - payload.products.map((product: { id: string }) => product.id) - ).toEqual(["prod_visible_2"]) - expect(payload.count).toBe(3) - expect(payload.page).toBe(2) - expect(payload.limit).toBe(1) - expect(payload.totalPages).toBe(3) - }) - - it("uses indexed visibility filters instead of scanning all Meili hits", async () => { - const products = Array.from({ length: 250 }, (_, index) => ({ - id: `prod_${index}`, - title: `Product ${index}`, - status: "published" as const, - salesChannelIds: ["sc_visible"], - })) - const { req, res, meiliSearch } = createCatalogHarness({ - products, - query: { - limit: 12, - }, - }) - - await GET(req, res) - - expect(meiliSearch).toHaveBeenCalledTimes(1) - const searchOptions = meiliSearch.mock.calls[0]?.[2] - expect(searchOptions?.filter).toContain( - 'facet_product_status = "published"' - ) - expect(searchOptions?.filter).toContain( - 'facet_sales_channel_ids = "sc_visible"' - ) - expect(getJsonPayload(res).products).toHaveLength(12) - expect(getJsonPayload(res).count).toBe(250) - }) - - it("derives facet counts and price stats only from visible products", async () => { - const { req, res } = createCatalogHarness({ - products: [ - { - id: "prod_hidden_channel", - title: "Hidden channel product", - status: "published", - salesChannelIds: ["sc_other"], - statusFacets: ["action"], - formFacets: ["form-tablets"], - brandFacets: ["brand-hidden"], - ingredientFacets: ["ingredient-hidden"], - facetPrice: 99, - }, - { - id: "prod_draft", - title: "Draft product", - status: "draft", - salesChannelIds: ["sc_visible"], - statusFacets: ["action"], - formFacets: ["form-tablets"], - brandFacets: ["brand-hidden"], - ingredientFacets: ["ingredient-hidden"], - facetPrice: 88, - }, - { - id: "prod_visible", - title: "Visible product", - status: "published", - salesChannelIds: ["sc_visible"], - statusFacets: ["in-stock"], - formFacets: ["form-capsules"], - brandFacets: ["brand-visible"], - ingredientFacets: ["ingredient-visible"], - facetPrice: 12, - }, - ], - }) - - await GET(req, res) - - const payload = getJsonPayload(res) - expect( - payload.facets.status.find((item: { id: string }) => item.id === "action") - .count - ).toBe(0) - expect( - payload.facets.status.find( - (item: { id: string }) => item.id === "in-stock" - ).count - ).toBe(1) - expect(payload.facets.brand).toEqual([ - { - id: "brand-visible", - label: "visible", - count: 1, - }, - ]) - expect(payload.facets.ingredient).toEqual([ - { - id: "ingredient-visible", - label: "visible", - count: 1, - }, - ]) - expect(payload.facets.price).toEqual({ - min: 12, - max: 12, - }) - }) -}) diff --git a/apps/medusa-be/tests/unit/catalog/catalog-products-utils.unit.spec.ts b/apps/medusa-be/tests/unit/catalog/catalog-products-utils.unit.spec.ts deleted file mode 100644 index 24b5483065..0000000000 --- a/apps/medusa-be/tests/unit/catalog/catalog-products-utils.unit.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it } from "vitest" - -import { - buildCatalogFilterExpressions, - normalizeBrandParam, - normalizeCategoryIdsParam, - normalizeFormParam, - normalizeIngredientParam, - normalizeStatusParam, - resolveCatalogSort, -} from "../../../src/api/store/catalog/products/utils" - -describe("catalog products filter utils", () => { - it("normalizes multi-value query params", () => { - expect(normalizeStatusParam("action,in-stock,unknown")).toEqual([ - "action", - "in-stock", - ]) - - expect( - normalizeFormParam(["form-capsules", "form-tablets,form-tablets"]) - ).toEqual(["form-capsules", "form-tablets"]) - - expect(normalizeBrandParam("brand-natura,invalid")).toEqual([ - "brand-natura", - ]) - expect(normalizeIngredientParam(["ingredient-horcik", "other"])).toEqual([ - "ingredient-horcik", - ]) - expect(normalizeCategoryIdsParam("pcat_01,pcat_02")).toEqual([ - "pcat_01", - "pcat_02", - ]) - }) - - it("builds meili filter expressions from normalized values", () => { - const expressions = buildCatalogFilterExpressions({ - categoryIds: ["pcat_01", "pcat_02"], - statusIds: ["action", "in-stock"], - formIds: ["form-capsules"], - brandIds: ["brand-natura"], - ingredientIds: ["ingredient-horcik"], - priceMin: 20, - priceMax: 10, - }) - - expect(expressions).toEqual([ - '(facet_category_ids = "pcat_01" OR facet_category_ids = "pcat_02")', - '(facet_status = "action" OR facet_status = "in-stock")', - 'facet_form = "form-capsules"', - 'facet_brand = "brand-natura"', - 'facet_ingredient = "ingredient-horcik"', - "facet_price >= 10", - "facet_price <= 20", - ]) - }) - - it("maps API sort values to meili sort expressions", () => { - expect(resolveCatalogSort("recommended")).toBeUndefined() - expect(resolveCatalogSort("best-selling")).toBeUndefined() - expect(resolveCatalogSort("newest")).toEqual(["created_at:desc"]) - expect(resolveCatalogSort("oldest")).toEqual(["created_at:asc"]) - expect(resolveCatalogSort("price-asc")).toEqual(["facet_price:asc"]) - expect(resolveCatalogSort("price-desc")).toEqual(["facet_price:desc"]) - expect(resolveCatalogSort("title-asc")).toEqual(["title:asc"]) - expect(resolveCatalogSort("title-desc")).toEqual(["title:desc"]) - }) -}) diff --git a/apps/medusa-be/tests/unit/catalog/product-facets.unit.spec.ts b/apps/medusa-be/tests/unit/catalog/product-facets.unit.spec.ts deleted file mode 100644 index ff44ce74cf..0000000000 --- a/apps/medusa-be/tests/unit/catalog/product-facets.unit.spec.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, expect, it } from "vitest" - -import { buildProductFacetDocument } from "../../../src/modules/meilisearch/facets/product-facets" - -describe("product facet document builder", () => { - it("derives all primary facets from product payload", () => { - const result = buildProductFacetDocument({ - status: "published", - title: "Horčík kapsuly", - metadata: { - category_paths: [ - "Doplnky výživy > Účinné zložky od A po Z > Horčík", - "Doplnky výživy > Forma > Kapsuly", - ], - flags: [ - { - code: "action", - active: true, - }, - ], - top_offer: { - current_price: 15.9, - stock: { - amount: 2, - }, - }, - }, - brand: { - handle: "natura-balance", - title: "Natura Balance", - }, - categories: [ - { - id: "pcat_01", - handle: "ucinne-zlozky-od-a-po-z-horcik", - name: "Horčík", - }, - ], - sales_channels: [ - { - id: "sc_visible", - }, - ], - }) - - expect(result.facet_product_status).toBe("published") - expect(result.facet_sales_channel_ids).toEqual(["sc_visible"]) - expect(result.facet_status).toEqual( - expect.arrayContaining(["in-stock", "action"]) - ) - expect(result.facet_form).toEqual(expect.arrayContaining(["form-capsules"])) - expect(result.facet_brand).toEqual(["brand-natura-balance"]) - expect(result.facet_ingredient).toEqual([ - "ingredient-ucinne-zlozky-od-a-po-z-horcik", - ]) - expect(result.facet_category_ids).toEqual(["pcat_01"]) - expect(result.facet_in_stock).toBe(true) - expect(result.facet_price).toBe(15.9) - }) - - it("falls back to variant price and marks unavailable stock", () => { - const result = buildProductFacetDocument({ - title: "Prírodný sirup", - metadata: { - top_offer: { - stock: { - amount: 0, - }, - }, - }, - variants: [ - { - prices: [ - { - amount: 1290, - currency_code: "eur", - }, - ], - }, - ], - }) - - expect(result.facet_in_stock).toBe(false) - expect(result.facet_price).toBe(12.9) - }) - - it("parses localized decimal values from top offer metadata", () => { - const result = buildProductFacetDocument({ - title: "Bylinné kvapky", - metadata: { - top_offer: { - current_price: "19,81", - stock: { - amount: 4, - }, - }, - }, - }) - - expect(result.facet_price).toBe(19.81) - }) - - it("ignores non-positive top offer price and falls back to variant price", () => { - const result = buildProductFacetDocument({ - title: "Pleťový olej", - metadata: { - top_offer: { - current_price: 0, - }, - }, - variants: [ - { - prices: [ - { - amount: 1990, - currency_code: "eur", - }, - ], - }, - ], - }) - - expect(result.facet_price).toBe(19.9) - }) -}) diff --git a/apps/medusa-be/tests/unit/src/config/medusa-config.unit.spec.ts b/apps/medusa-be/tests/unit/src/config/medusa-config.unit.spec.ts index 75a03a6655..7f910e16dc 100644 --- a/apps/medusa-be/tests/unit/src/config/medusa-config.unit.spec.ts +++ b/apps/medusa-be/tests/unit/src/config/medusa-config.unit.spec.ts @@ -219,22 +219,6 @@ describe("readMedusaConfigEnv", () => { ]) }) - it("does not register Meilisearch when search is disabled", () => { - const env = readMedusaConfigEnv({ - ...baseEnv, - MEILISEARCH_ENABLED: "0", - }) - - expect(env.meilisearchHost).toBeUndefined() - expect(buildPlugins(env)).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - resolve: "@rokmohar/medusa-plugin-meilisearch", - }), - ]) - ) - }) - it("keeps project CORS values separate", () => { const env = readMedusaConfigEnv({ ...baseEnv, diff --git a/apps/new-engine-ctl/config/stack-inputs.yaml b/apps/new-engine-ctl/config/stack-inputs.yaml index 9cb182a98d..aebdc76fc6 100644 --- a/apps/new-engine-ctl/config/stack-inputs.yaml +++ b/apps/new-engine-ctl/config/stack-inputs.yaml @@ -216,6 +216,7 @@ runtime_providers: - documents.add - documents.delete - indexes.create + - indexes.delete - indexes.get - indexes.update - indexes.swap @@ -226,6 +227,11 @@ runtime_providers: - products - categories - brands + - content + - product_* + - category_* + - brand_* + - content_* - output_id: frontend_key target_envs: diff --git a/apps/payload/src/lib/hooks/medusa-cache.ts b/apps/payload/src/lib/hooks/medusa-cache.ts index ac96a9495c..6298e45284 100644 --- a/apps/payload/src/lib/hooks/medusa-cache.ts +++ b/apps/payload/src/lib/hooks/medusa-cache.ts @@ -11,17 +11,15 @@ import { createRequestTimeout } from "../utils/request" /** Payload invalidation payload sent to Medusa. */ type MedusaInvalidatePayload = { collection: string - doc?: { - id?: string - slug?: string - locale?: string - } + doc?: Record + operation?: string } /** Minimal CMS document shape for invalidation metadata. */ type CmsDoc = { id?: string | number slug?: string | Record + [key: string]: unknown } /** Track whether the missing base URL warning has already been logged. */ @@ -139,7 +137,14 @@ export const createMedusaCacheHook = ( id: cmsDoc?.id ? String(cmsDoc.id) : undefined, slug: resolveSlug(cmsDoc, locale), locale, + title: cmsDoc?.title, + excerpt: cmsDoc?.excerpt, + content: cmsDoc?.content, + contentHTML: cmsDoc?.contentHTML, + status: cmsDoc?.status, + visibility: cmsDoc?.visibility, }, + operation: op, } req?.payload?.logger?.info?.( diff --git a/libs/storefront-data/src/catalog/medusa-service.ts b/libs/storefront-data/src/catalog/medusa-service.ts index d62a2ff7d3..483a766c3f 100644 --- a/libs/storefront-data/src/catalog/medusa-service.ts +++ b/libs/storefront-data/src/catalog/medusa-service.ts @@ -38,6 +38,7 @@ export type MedusaCatalogServiceConfig< listPath?: string defaultLimit?: number defaultSort?: string + queryDefaults?: MedusaCatalogListQuery normalizeListQuery?: (params: TListParams) => MedusaCatalogListQuery transformProduct?: (product: HttpTypes.StoreProduct) => TProduct transformListProduct?: ( @@ -268,6 +269,7 @@ export function createMedusaCatalogService< listPath = "/store/catalog/products", defaultLimit = 12, defaultSort = "recommended", + queryDefaults, normalizeListQuery, transformProduct, transformListProduct, @@ -287,10 +289,16 @@ export function createMedusaCatalogService< const buildListQuery = (params: TListParams): MedusaCatalogListQuery => { if (normalizeListQuery) { - return stripNullishValues(normalizeListQuery(params)) + return stripNullishValues({ + ...queryDefaults, + ...normalizeListQuery(params), + }) } - return buildDefaultListQuery(params, { defaultLimit, defaultSort }) + return stripNullishValues({ + ...queryDefaults, + ...buildDefaultListQuery(params, { defaultLimit, defaultSort }), + }) } return { diff --git a/patches/@rokmohar__medusa-plugin-meilisearch@1.4.3.patch b/patches/@rokmohar__medusa-plugin-meilisearch@1.4.3.patch new file mode 100644 index 0000000000..103d2fc14b --- /dev/null +++ b/patches/@rokmohar__medusa-plugin-meilisearch@1.4.3.patch @@ -0,0 +1,16 @@ +diff --git a/package.json b/package.json +index a056975..d5bbcb2 100644 +--- a/package.json ++++ b/package.json +@@ -22,11 +22,6 @@ + "main": ".medusa/server/src/index.js", + "exports": { + "./package.json": "./package.json", +- "./admin": { +- "import": "./.medusa/server/src/admin/index.mjs", +- "require": "./.medusa/server/src/admin/index.js", +- "default": "./.medusa/server/src/admin/index.js" +- }, + "./workflows": "./.medusa/server/src/workflows/index.js", + "./.medusa/server/src/modules/*": "./.medusa/server/src/modules/*/index.js", + ".": "./.medusa/server/src/modules/meilisearch/index.js" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fea8e55957..a4f2b3ccd2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,7 @@ packageExtensionsChecksum: sha256-bfmqySvf7v98NPrRPcdoQkmEmUgdZs7NYgYf9oapspM= patchedDependencies: '@lexical/code@0.35.0': 38ef93d18a7c26b06c6c76116b49aa8fca1128da4eb27c3cda2b27d210788fd6 + '@rokmohar/medusa-plugin-meilisearch@1.4.3': 2c4bd4dbbc19dd75662a8779e611e20b5726274443f18c32d678e388f8af56de object-inspect@1.13.4: 901c1ec93cfdee21c7fbef4ed06bc68e8f176055c3c556d9a5ea9fabcb9cc0eb importers: @@ -388,7 +389,7 @@ importers: version: 1.1.1(@types/react@19.2.3)(react@19.2.3) '@rokmohar/medusa-plugin-meilisearch': specifier: ^1.4.3 - version: 1.4.3(@medusajs/admin-sdk@2.18.0)(@medusajs/framework@2.18.0(@medusajs/cli@2.18.0(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1))(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(ioredis@5.11.1(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1)(vite@5.4.21(@types/node@24.12.4)(less@4.1.3)(lightningcss@1.33.0)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0))(zod@4.4.3))(@medusajs/medusa@2.18.0)(@medusajs/ui@4.2.0(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(@tanstack/react-query@5.64.2(react@19.2.3))(@types/node@24.12.4)(awilix@8.0.1)(express@4.22.2(supports-color@8.1.1))(mysql2@3.15.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(supports-color@8.1.1) + version: 1.4.3(patch_hash=2c4bd4dbbc19dd75662a8779e611e20b5726274443f18c32d678e388f8af56de)(@medusajs/admin-sdk@2.18.0)(@medusajs/framework@2.18.0(@medusajs/cli@2.18.0(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1))(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(ioredis@5.11.1(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1)(vite@5.4.21(@types/node@24.12.4)(less@4.1.3)(lightningcss@1.33.0)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0))(zod@4.4.3))(@medusajs/medusa@2.18.0)(@medusajs/ui@4.2.0(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(@tanstack/react-query@5.64.2(react@19.2.3))(@types/node@24.12.4)(awilix@8.0.1)(express@4.22.2(supports-color@8.1.1))(mysql2@3.15.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(supports-color@8.1.1) '@sentry/node': specifier: ^10.67.0 version: 10.67.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.220.0(@opentelemetry/api@1.9.1))(supports-color@8.1.1) @@ -27231,7 +27232,7 @@ snapshots: '@remix-run/router@1.23.3': {} - '@rokmohar/medusa-plugin-meilisearch@1.4.3(@medusajs/admin-sdk@2.18.0)(@medusajs/framework@2.18.0(@medusajs/cli@2.18.0(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1))(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(ioredis@5.11.1(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1)(vite@5.4.21(@types/node@24.12.4)(less@4.1.3)(lightningcss@1.33.0)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0))(zod@4.4.3))(@medusajs/medusa@2.18.0)(@medusajs/ui@4.2.0(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(@tanstack/react-query@5.64.2(react@19.2.3))(@types/node@24.12.4)(awilix@8.0.1)(express@4.22.2(supports-color@8.1.1))(mysql2@3.15.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(supports-color@8.1.1)': + '@rokmohar/medusa-plugin-meilisearch@1.4.3(patch_hash=2c4bd4dbbc19dd75662a8779e611e20b5726274443f18c32d678e388f8af56de)(@medusajs/admin-sdk@2.18.0)(@medusajs/framework@2.18.0(@medusajs/cli@2.18.0(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1))(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(ioredis@5.11.1(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1)(vite@5.4.21(@types/node@24.12.4)(less@4.1.3)(lightningcss@1.33.0)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0))(zod@4.4.3))(@medusajs/medusa@2.18.0)(@medusajs/ui@4.2.0(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(@tanstack/react-query@5.64.2(react@19.2.3))(@types/node@24.12.4)(awilix@8.0.1)(express@4.22.2(supports-color@8.1.1))(mysql2@3.15.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(supports-color@8.1.1)': dependencies: '@medusajs/admin-sdk': 2.18.0 '@medusajs/framework': 2.18.0(@medusajs/cli@2.18.0(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1))(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(ioredis@5.11.1(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1)(vite@5.4.21(@types/node@24.12.4)(less@4.1.3)(lightningcss@1.33.0)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0))(zod@4.4.3) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0955ce482f..b0ca3dd413 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -132,6 +132,7 @@ packageExtensions: patchedDependencies: '@lexical/code@0.35.0': patches/@lexical__code@0.35.0.patch + '@rokmohar/medusa-plugin-meilisearch@1.4.3': patches/@rokmohar__medusa-plugin-meilisearch@1.4.3.patch object-inspect@1.13.4: patches/object-inspect@1.13.4.patch packages: