import type { MedusaRequest, MedusaResponse } from "@medusajs/framework"; import { ContainerRegistrationKeys, QueryContext, } from "@medusajs/framework/utils"; import { MEILISEARCH_MODULE, type MeiliSearchService, } from "@rokmohar/medusa-plugin-meilisearch"; // Las rutas /store/meilisearch/* que trae el plugin (ver ADR 0006 nota de // implementacion) no devuelven facetDistribution -- necesitamos esta // ruta propia para pedirlo explicitamente via additionalOptions.facets, // que el servicio del plugin reenvia tal cual al cliente Meilisearch. const FACETS = ["category", "collection", "brand", "ageRange"]; // Mismos campos que ya usa el storefront contra /store/products // (workloads/ecommerce/lib/headless/providers/medusa.ts) -- una vez que // Meilisearch nos da los ids candidatos, volvemos a Medusa via // query.graph para precio/inventario reales, nunca confiamos en el // valor indexado (puede tener unos segundos de retraso respecto al // evento de sincronizacion). const PRODUCT_FIELDS = [ "id", "handle", "title", "subtitle", "description", "thumbnail", "external_id", "metadata", "images.url", "tags.value", "categories.name", "collection.title", "variants.id", "variants.title", "variants.sku", "variants.manage_inventory", "variants.allow_backorder", "variants.inventory_quantity", "variants.calculated_price.calculated_amount", "variants.calculated_price.original_amount", "variants.calculated_price.currency_code", "variants.options.value", "variants.options.option.title", ]; function stringParam( query: MedusaRequest["query"], name: string, ): string | undefined { const value = query[name]; return typeof value === "string" && value.length > 0 ? value : undefined; } function numberParam( query: MedusaRequest["query"], name: string, ): number | undefined { const value = stringParam(query, name); if (!value) return undefined; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : undefined; } // Meilisearch usa comillas dobles para valores de filtro con espacios; // escapamos las que vengan en el propio valor para no romper la sintaxis. function escapeFilterValue(value: string): string { return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } export async function GET(req: MedusaRequest, res: MedusaResponse) { const meilisearchService = req.scope.resolve( MEILISEARCH_MODULE, ); const query = req.scope.resolve(ContainerRegistrationKeys.QUERY); const q = stringParam(req.query, "q") ?? ""; const limit = numberParam(req.query, "limit") ?? 24; const offset = numberParam(req.query, "offset") ?? 0; // calculated_price exige region_id/currency_code en el contexto de // pricing o query.graph tira "Method calculatePrices requires // currency_code in the pricing context" (mismo error que ya rompio el // job de sync -- ver medusa-config.ts). El storefront ya manda // region_id contra /store/products (medusa.ts, regionId()); esta ruta // espera el mismo query param. const regionId = stringParam(req.query, "region_id"); const filterParts: string[] = []; for (const facet of ["category", "collection", "brand", "ageRange"]) { const value = stringParam(req.query, facet); if (value) { filterParts.push(`${facet} = "${escapeFilterValue(value)}"`); } } const minPrice = numberParam(req.query, "minPrice"); const maxPrice = numberParam(req.query, "maxPrice"); if (typeof minPrice === "number") filterParts.push(`price >= ${minPrice}`); if (typeof maxPrice === "number") filterParts.push(`price <= ${maxPrice}`); const indexes = await meilisearchService.getIndexesByType("products"); if (indexes.length === 0) { // Meilisearch no configurado o el indice "products" esta // deshabilitado -- degradar a "sin resultados" en vez de 500, el // frontend cae de nuevo a su propio filtrado si esta ruta falla. res.json({ products: [], facetDistribution: {}, count: 0, limit, offset }); return; } const results = await Promise.all( indexes.map((indexKey) => meilisearchService.search(indexKey, q, { filter: filterParts.length ? filterParts.join(" AND ") : undefined, paginationOptions: { limit, offset }, additionalOptions: { facets: FACETS }, }), ), ); const hits = results.flatMap((result) => result.hits); const totalCount = results.reduce( (sum, result) => sum + result.estimatedTotalHits, 0, ); const facetDistribution: Record> = {}; for (const result of results) { const resultFacets = ( result as unknown as { facetDistribution?: Record>; } ).facetDistribution; if (!resultFacets) continue; for (const [facet, values] of Object.entries(resultFacets)) { facetDistribution[facet] ??= {}; for (const [value, count] of Object.entries(values)) { facetDistribution[facet][value] = (facetDistribution[facet][value] ?? 0) + count; } } } const productIds = hits.map((hit) => (hit as { id: string }).id); if (productIds.length === 0) { res.json({ products: [], facetDistribution, count: 0, limit, offset }); return; } // calculatePrices exige currency_code en el contexto -- no alcanza con // region_id solo (a diferencia de /store/products nativo, que lo // resuelve el middleware setPricingContext; esta ruta no pasa por esa // cadena). Resolvemos la moneda real de la region en vez de asumir COP // fijo, por si el catalogo suma otra region/moneda mas adelante. let currencyCode: string | undefined; if (regionId) { const { data: regions = [] } = await query.graph({ entity: "region", fields: ["id", "currency_code"], filters: { id: regionId }, }); currencyCode = regions[0]?.currency_code; } const { data: products = [] } = await query.graph({ entity: "product", fields: PRODUCT_FIELDS, filters: { id: productIds }, context: regionId && currencyCode ? { variants: { calculated_price: QueryContext({ region_id: regionId, currency_code: currencyCode, }), }, } : {}, }); // Preserva el ranking de relevancia de Meilisearch -- query.graph no // garantiza el mismo orden que la busqueda. const orderedProducts = [...products].sort( (a, b) => productIds.indexOf(a.id) - productIds.indexOf(b.id), ); res.json({ products: orderedProducts, facetDistribution, count: totalCount, limit, offset, }); }