Mismo error encontrado en el gate de verificacion, ahora en la ruta propia en vez del job de sync: "Method calculatePrices requires currency_code in the pricing context" -- query.graph necesita region_id/currency_code en el contexto para resolver variants.calculated_price. El storefront ya manda region_id contra /store/products (medusa.ts, regionId()); esta ruta ahora acepta el mismo query param y arma el contexto de pricing con QueryContext(), igual que la ruta oficial /store/meilisearch/products del plugin.
175 lines
5.8 KiB
TypeScript
175 lines
5.8 KiB
TypeScript
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<MeiliSearchService>(
|
|
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<string, Record<string, number>> = {};
|
|
for (const result of results) {
|
|
const resultFacets = (
|
|
result as unknown as {
|
|
facetDistribution?: Record<string, Record<string, number>>;
|
|
}
|
|
).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 },
|
|
context: regionId
|
|
? { variants: { calculated_price: QueryContext({ region_id: regionId }) } }
|
|
: {},
|
|
});
|
|
|
|
// 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,
|
|
});
|
|
}
|