diff --git a/workloads/commerce-backend/Dockerfile b/workloads/commerce-backend/Dockerfile index 9712a11..2012783 100644 --- a/workloads/commerce-backend/Dockerfile +++ b/workloads/commerce-backend/Dockerfile @@ -21,7 +21,9 @@ ENV CI=true \ S3_ACCESS_KEY_ID=build-access-key \ S3_SECRET_ACCESS_KEY=build-secret-key \ S3_BUCKET=ari-shopping \ - S3_ENDPOINT=http://127.0.0.1:9000 + S3_ENDPOINT=http://127.0.0.1:9000 \ + MEILISEARCH_HOST=http://127.0.0.1:7700 \ + MEILISEARCH_API_KEY=build-only-meili-key COPY package.json package-lock.json .npmrc ./ diff --git a/workloads/commerce-backend/medusa-config.ts b/workloads/commerce-backend/medusa-config.ts index 7c9b52e..ee68289 100644 --- a/workloads/commerce-backend/medusa-config.ts +++ b/workloads/commerce-backend/medusa-config.ts @@ -10,6 +10,79 @@ const required = (name: string): string => { return value } +// Mismos campos derivados de metadata/categories/collection/variants que +// mapProduct() en workloads/ecommerce/lib/headless/providers/medusa.ts -- +// el documento indexado debe reflejar exactamente lo que el storefront +// ya muestra, no una vista distinta del catálogo. +function stringMetadata( + metadata: Record | null | undefined, + key: string, +): string { + const value = metadata?.[key] + return typeof value === "string" ? value : "" +} + +function booleanMetadata( + metadata: Record | null | undefined, + key: string, +): boolean { + const value = metadata?.[key] + return value === true || value === "true" +} + +async function meilisearchProductTransformer( + document: Record, +) { + const metadata = (document.metadata ?? null) as Record< + string, + unknown + > | null + const categories = document.categories as + | Array<{ name?: string | null }> + | null + | undefined + const collection = document.collection as { title?: string | null } | null + const variants = document.variants as + | Array<{ + inventory_quantity?: number | null + manage_inventory?: boolean | null + allow_backorder?: boolean | null + calculated_price?: { calculated_amount?: number | null } | null + }> + | null + | undefined + const variant = variants?.[0] + const tags = document.tags as Array<{ value?: string | null }> | null + + const category = + categories?.[0]?.name || stringMetadata(metadata, "category") || "Variedades" + + const available = + !variant || + variant.allow_backorder === true || + variant.manage_inventory === false || + (variant.inventory_quantity ?? 0) > 0 + + return { + id: document.id, + name: document.title || "Producto ARI Shopping", + shortDescription: document.subtitle || document.title || "", + description: document.description || "", + category, + collection: collection?.title || stringMetadata(metadata, "collection") || "", + brand: stringMetadata(metadata, "brand") || "ARI Shopping", + ageRange: stringMetadata(metadata, "ageRange") || "Todas", + tags: (tags || []).map((tag) => tag.value).filter(Boolean), + price: + typeof variant?.calculated_price?.calculated_amount === "number" + ? variant.calculated_price.calculated_amount + : null, + available, + isNew: booleanMetadata(metadata, "isNew"), + featured: booleanMetadata(metadata, "featured"), + } +} + module.exports = defineConfig({ projectConfig: { databaseUrl: required("DATABASE_URL"), @@ -73,4 +146,71 @@ module.exports = defineConfig({ }, }, ], + + // ADR 0006 Etapa 2. Sin required(): a diferencia de S3/DB, Meilisearch + // es un servicio nuevo y todavia no probado en produccion en un host + // con historial de swap saturado -- si esta caido o mal configurado, + // el plugin se degrada solo (soporta enabled:false, "silently skipped" + // segun su propio README) en vez de tumbar el arranque de Medusa + // entero, que es lo que si haria required(). + plugins: [ + { + resolve: "@rokmohar/medusa-plugin-meilisearch", + options: { + config: { + host: process.env.MEILISEARCH_HOST ?? "", + apiKey: process.env.MEILISEARCH_API_KEY ?? "", + }, + settings: { + products: { + type: "products", + enabled: Boolean(process.env.MEILISEARCH_HOST), + primaryKey: "id", + // Campos que el plugin pide via query.graph antes de pasarlos + // al transformer de arriba -- si falta alguno acá, llega + // undefined al transformer sin importar lo que este haga. + fields: [ + "id", + "title", + "subtitle", + "description", + "handle", + "status", + "thumbnail", + "metadata", + "tags.value", + "categories.name", + "collection.title", + "variants.inventory_quantity", + "variants.manage_inventory", + "variants.allow_backorder", + "variants.calculated_price.calculated_amount", + ], + indexSettings: { + // Tabla de ADR 0006 §3: Buscable / Filtrable-faceta / Ordenable. + searchableAttributes: [ + "name", + "shortDescription", + "description", + "category", + "collection", + "brand", + "tags", + ], + filterableAttributes: [ + "category", + "collection", + "brand", + "ageRange", + "price", + "available", + ], + sortableAttributes: ["price", "isNew", "featured"], + }, + transformer: meilisearchProductTransformer, + }, + }, + }, + }, + ], }) diff --git a/workloads/commerce-backend/package-lock.json b/workloads/commerce-backend/package-lock.json index 5379268..dec66cd 100644 --- a/workloads/commerce-backend/package-lock.json +++ b/workloads/commerce-backend/package-lock.json @@ -12,7 +12,8 @@ "@medusajs/admin-sdk": "2.18.0", "@medusajs/cli": "2.18.0", "@medusajs/framework": "2.18.0", - "@medusajs/medusa": "2.18.0" + "@medusajs/medusa": "2.18.0", + "@rokmohar/medusa-plugin-meilisearch": "^1.4.1" }, "devDependencies": { "@swc/core": "^1.7.28", @@ -6561,6 +6562,28 @@ "node": ">=14.0.0" } }, + "node_modules/@rokmohar/medusa-plugin-meilisearch": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@rokmohar/medusa-plugin-meilisearch/-/medusa-plugin-meilisearch-1.4.3.tgz", + "integrity": "sha512-+iStv5sc2vuLk+O7OcQLlNR99fMBhGdBOJMCmDJYoTFGhzBA4ZEV18IIr2xqxUDgdQo1haF0TtYv3JmRQUuY1w==", + "license": "MIT", + "dependencies": { + "meilisearch": "^0.56.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@medusajs/admin-sdk": "^2.15.2", + "@medusajs/framework": "^2.15.2", + "@medusajs/medusa": "^2.15.2", + "@medusajs/ui": "^4.1.12", + "@tanstack/react-query": "5.64.2", + "awilix": "^8.0.1", + "react": "^18", + "react-dom": "^18" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -11774,6 +11797,12 @@ "node": ">= 0.6" } }, + "node_modules/meilisearch": { + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/meilisearch/-/meilisearch-0.56.0.tgz", + "integrity": "sha512-kBXba8DcSrLWHYqopCm2JL90oBy97VdIfHkP1ii7/eHufeEEEk9Zu5Vv/IFQpNzmhEpWszVFVlGvZoI6QJeGzA==", + "license": "MIT" + }, "node_modules/merge-descriptors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", diff --git a/workloads/commerce-backend/package.json b/workloads/commerce-backend/package.json index 7eafa91..f981fa9 100644 --- a/workloads/commerce-backend/package.json +++ b/workloads/commerce-backend/package.json @@ -14,7 +14,8 @@ "@medusajs/admin-sdk": "2.18.0", "@medusajs/cli": "2.18.0", "@medusajs/framework": "2.18.0", - "@medusajs/medusa": "2.18.0" + "@medusajs/medusa": "2.18.0", + "@rokmohar/medusa-plugin-meilisearch": "^1.4.1" }, "devDependencies": { "@swc/core": "^1.7.28", diff --git a/workloads/commerce-backend/src/api/store/search/route.ts b/workloads/commerce-backend/src/api/store/search/route.ts new file mode 100644 index 0000000..0c18948 --- /dev/null +++ b/workloads/commerce-backend/src/api/store/search/route.ts @@ -0,0 +1,161 @@ +import type { MedusaRequest, MedusaResponse } from "@medusajs/framework"; +import { ContainerRegistrationKeys } 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; + + 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; + } + + const { data: products = [] } = await query.graph({ + entity: "product", + fields: PRODUCT_FIELDS, + filters: { id: productIds }, + }); + + // 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, + }); +}