Confirmado en vivo: pasar solo region_id a QueryContext no alcanza -- "Method calculatePrices requires currency_code in the pricing context" persistia. A diferencia de /store/products nativo (resuelto por el middleware setPricingContext), esta ruta propia no pasa por esa cadena, asi que resuelve la moneda real de la region con un query.graph adicional antes de pedir calculated_price -- no asume COP fijo por si el catalogo suma otra region/moneda despues.
198 lines
6.5 KiB
TypeScript
198 lines
6.5 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;
|
|
}
|
|
|
|
// 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,
|
|
});
|
|
}
|