implementando openAI y busqueda de productos en woo

This commit is contained in:
Lucas Tettamanti
2026-01-05 11:15:56 -03:00
parent 829823ac3d
commit dab52492b4
9 changed files with 570 additions and 48 deletions

View File

@@ -2,7 +2,7 @@
- Integrar WooCommerce real en `src/services/woo.js` (reemplazar stub `createWooCustomer` con llamadas a la API y manejo de errores; usar creds/config desde env). - Integrar WooCommerce real en `src/services/woo.js` (reemplazar stub `createWooCustomer` con llamadas a la API y manejo de errores; usar creds/config desde env).
- Pipeline: cuando Woo devuelva el cliente real, mantener/actualizar el mapping en `wa_identity_map` vía `upsertWooCustomerMap`. - Pipeline: cuando Woo devuelva el cliente real, mantener/actualizar el mapping en `wa_identity_map` vía `upsertWooCustomerMap`.
- Conectar con OpenAI en `src/services/pipeline.js` usando `llmInput` y validar el output con esquema (Zod) antes de guardar el run. - Conectar con OpenAI en `src/services/pipeline.js` usando `llmInput` y validar el output con esquema (Zod) antes de guardar el run. (Hecho) — env: `OPENAI_API_KEY`, opcional `OPENAI_MODEL`.
- (Opcional) Endpoint interno para forzar/upsert de mapping Woo ↔ wa_chat_id, reutilizando repo/woo service. - (Opcional) Endpoint interno para forzar/upsert de mapping Woo ↔ wa_chat_id, reutilizando repo/woo service.
- Revisar manejo de multi-tenant en simulador/UI (instance/tenant_key) y asegurar consistencia en `resolveTenantId`/webhooks. - Revisar manejo de multi-tenant en simulador/UI (instance/tenant_key) y asegurar consistencia en `resolveTenantId`/webhooks.
- Enterprise: mover credenciales de Woo (u otras tiendas) a secret manager (Vault/AWS SM/etc.), solo referenciarlas desde DB por clave/ID; auditar acceso a secretos y mapping; soportar rotación de keys. - Enterprise: mover credenciales de Woo (u otras tiendas) a secret manager (Vault/AWS SM/etc.), solo referenciarlas desde DB por clave/ID; auditar acceso a secretos y mapping; soportar rotación de keys.

View File

@@ -0,0 +1,21 @@
-- migrate:up
create table if not exists woo_products_cache (
tenant_id uuid not null references tenants(id) on delete cascade,
woo_product_id integer not null,
name text not null,
sku text,
price numeric(12,2),
currency text,
refreshed_at timestamptz not null default now(),
payload jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
primary key (tenant_id, woo_product_id)
);
create index if not exists woo_products_cache_tenant_name_idx on woo_products_cache (tenant_id, lower(name));
create index if not exists woo_products_cache_tenant_sku_idx on woo_products_cache (tenant_id, sku);
-- migrate:down
drop table if exists woo_products_cache;

View File

@@ -11,6 +11,7 @@ import { makeSimSend } from "./src/controllers/sim.js";
import { makeEvolutionWebhook } from "./src/controllers/evolution.js"; import { makeEvolutionWebhook } from "./src/controllers/evolution.js";
import { makeGetConversationState } from "./src/controllers/conversationState.js"; import { makeGetConversationState } from "./src/controllers/conversationState.js";
import { makeListMessages } from "./src/controllers/messages.js"; import { makeListMessages } from "./src/controllers/messages.js";
import { makeSearchProducts } from "./src/controllers/products.js";
async function configureUndiciDispatcher() { async function configureUndiciDispatcher() {
// Node 18+ usa undici debajo de fetch. Esto suele arreglar timeouts “fantasma” por keep-alive/pooling. // Node 18+ usa undici debajo de fetch. Esto suele arreglar timeouts “fantasma” por keep-alive/pooling.
@@ -77,6 +78,7 @@ app.post("/sim/send", makeSimSend());
app.get("/conversations", makeGetConversations(() => TENANT_ID)); app.get("/conversations", makeGetConversations(() => TENANT_ID));
app.get("/conversations/state", makeGetConversationState(() => TENANT_ID)); app.get("/conversations/state", makeGetConversationState(() => TENANT_ID));
app.get("/messages", makeListMessages(() => TENANT_ID)); app.get("/messages", makeListMessages(() => TENANT_ID));
app.get("/products", makeSearchProducts(() => TENANT_ID));
app.get("/runs", makeListRuns(() => TENANT_ID)); app.get("/runs", makeListRuns(() => TENANT_ID));

View File

@@ -0,0 +1,17 @@
import { handleSearchProducts } from "../handlers/products.js";
export const makeSearchProducts = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const q = req.query.q || "";
const limit = req.query.limit || "10";
const forceWoo = req.query.forceWoo || "0";
const result = await handleSearchProducts({ tenantId, q, limit, forceWoo });
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};

View File

@@ -372,3 +372,95 @@ export async function getDecryptedTenantEcommerceConfig({
const { rows } = await pool.query(q, [tenant_id, provider, encryption_key]); const { rows } = await pool.query(q, [tenant_id, provider, encryption_key]);
return rows[0] || null; return rows[0] || null;
} }
export async function upsertWooProductCache({
tenant_id,
woo_product_id,
name,
sku = null,
price = null,
currency = null,
payload = {},
refreshed_at = null,
}) {
const q = `
insert into woo_products_cache
(tenant_id, woo_product_id, name, sku, price, currency, refreshed_at, payload, created_at, updated_at)
values
($1, $2, $3, $4, $5, $6, coalesce($7::timestamptz, now()), $8::jsonb, now(), now())
on conflict (tenant_id, woo_product_id)
do update set
name = excluded.name,
sku = excluded.sku,
price = excluded.price,
currency = excluded.currency,
refreshed_at = excluded.refreshed_at,
payload = excluded.payload,
updated_at = now()
returning tenant_id, woo_product_id, name, sku, price, currency, refreshed_at, payload, updated_at
`;
const { rows } = await pool.query(q, [
tenant_id,
woo_product_id,
name,
sku,
price,
currency,
refreshed_at,
JSON.stringify(payload ?? {}),
]);
return rows[0] || null;
}
export async function searchWooProductCache({ tenant_id, q = "", limit = 20 }) {
const lim = Math.max(1, Math.min(200, parseInt(limit, 10) || 20));
const query = String(q || "").trim();
if (!query) return [];
// Búsqueda simple: name o sku (ilike). Más adelante: trigram/FTS si hace falta.
const like = `%${query}%`;
const sql = `
select tenant_id, woo_product_id, name, sku, price, currency, refreshed_at, payload, updated_at
from woo_products_cache
where tenant_id=$1
and (name ilike $2 or coalesce(sku,'') ilike $2)
order by refreshed_at desc
limit $3
`;
const { rows } = await pool.query(sql, [tenant_id, like, lim]);
return rows.map((r) => ({
tenant_id: r.tenant_id,
woo_product_id: r.woo_product_id,
name: r.name,
sku: r.sku,
price: r.price,
currency: r.currency,
refreshed_at: r.refreshed_at,
payload: r.payload,
updated_at: r.updated_at,
}));
}
export async function getWooProductCacheById({ tenant_id, woo_product_id }) {
const sql = `
select tenant_id, woo_product_id, name, sku, price, currency, refreshed_at, payload, updated_at
from woo_products_cache
where tenant_id=$1 and woo_product_id=$2
limit 1
`;
const { rows } = await pool.query(sql, [tenant_id, woo_product_id]);
const r = rows[0];
if (!r) return null;
return {
tenant_id: r.tenant_id,
woo_product_id: r.woo_product_id,
name: r.name,
sku: r.sku,
price: r.price,
currency: r.currency,
refreshed_at: r.refreshed_at,
payload: r.payload,
updated_at: r.updated_at,
};
}

13
src/handlers/products.js Normal file
View File

@@ -0,0 +1,13 @@
import { searchProducts } from "../services/wooProducts.js";
export async function handleSearchProducts({ tenantId, q = "", limit = "10", forceWoo = "0" }) {
const { items, source } = await searchProducts({
tenantId,
q,
limit: parseInt(limit, 10) || 10,
forceWoo: String(forceWoo) === "1" || String(forceWoo).toLowerCase() === "true",
});
return { items, source };
}

View File

@@ -1,21 +1,111 @@
// src/services/openai.js (o directo en main.js por ahora)
import OpenAI from "openai"; import OpenAI from "openai";
import { z } from "zod";
export const openai = new OpenAI({ apiKey: process.env.OPENAI_APIKEY }); const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY;
// promptSystem = tu prompt (no lo tocamos mucho) export const openai = new OpenAI({ apiKey });
// input = { last_user_message, conversation_history, current_conversation_state, ... }
export async function llmPlan({ promptSystem, input }) { const NextStateSchema = z.enum([
const resp = await openai.responses.create({ "IDLE",
model: "gpt-5-mini", // o gpt-5 (más caro/mejor) / el que estés usando "BROWSING",
input: [ "BUILDING_ORDER",
{ role: "system", content: promptSystem }, "WAITING_ADDRESS",
{ role: "user", content: JSON.stringify(llmInput) } "WAITING_PAYMENT",
"COMPLETED",
]);
const IntentSchema = z.enum([
"ask_recommendation",
"ask_price",
"browse_products",
"create_order",
"add_item",
"remove_item",
"checkout",
"provide_address",
"confirm_payment",
"track_order",
"other",
]);
const OrderActionSchema = z.enum(["none", "create", "update", "cancel", "checkout"]);
const BasketItemSchema = z.object({
product_id: z.number().int().nonnegative(),
variation_id: z.number().int().nonnegative().nullable(),
quantity: z.number().positive(),
unit: z.enum(["kg", "g", "unit"]),
label: z.string().min(1),
});
const PlanSchema = z
.object({
reply: z.string().min(1).max(350).catch(z.string().min(1)), // respetar guideline, sin romper si excede
next_state: NextStateSchema,
intent: IntentSchema,
missing_fields: z.array(z.string()).default([]),
order_action: OrderActionSchema.default("none"),
basket_resolved: z
.object({
items: z.array(BasketItemSchema).default([]),
})
.default({ items: [] }),
})
.strict();
function extractJsonObject(text) {
const s = String(text || "");
const i = s.indexOf("{");
const j = s.lastIndexOf("}");
if (i >= 0 && j > i) return s.slice(i, j + 1);
return null;
}
/**
* Genera un "plan" de conversación (salida estructurada) usando OpenAI.
*
* - `promptSystem`: instrucciones del bot
* - `input`: { last_user_message, conversation_history, current_conversation_state, context }
*/
export async function llmPlan({ promptSystem, input, model } = {}) {
if (!apiKey) {
const err = new Error("OPENAI_API_KEY is not set");
err.code = "OPENAI_NO_KEY";
throw err;
}
const chosenModel = model || process.env.OPENAI_MODEL || "gpt-4o-mini";
const resp = await openai.chat.completions.create({
model: chosenModel,
temperature: 0.2,
response_format: { type: "json_object" },
messages: [
{
role: "system",
content:
`${promptSystem}\n\n` +
"Respondé SOLO con un JSON válido (sin markdown). Respetá estrictamente el formato requerido.",
},
{ role: "user", content: JSON.stringify(input ?? {}) },
], ],
// Si estás usando "Structured Outputs" nativo, acá va tu schema.
// En caso de que tu SDK no lo soporte directo, lo hacemos con zod/JSON parse robusto.
}); });
const text = resp.output_text; // ojo: depende del SDK/model; es el agregado de outputs const text = resp?.choices?.[0]?.message?.content || "";
return text; let parsed;
try {
parsed = JSON.parse(text);
} catch {
const extracted = extractJsonObject(text);
if (!extracted) throw new Error("openai_invalid_json");
parsed = JSON.parse(extracted);
}
const plan = PlanSchema.parse(parsed);
return {
plan,
raw_text: text,
model: chosenModel,
usage: resp?.usage || null,
};
} }

View File

@@ -11,6 +11,7 @@ import {
} from "../db/repo.js"; } from "../db/repo.js";
import { sseSend } from "./sse.js"; import { sseSend } from "./sse.js";
import { createWooCustomer, getWooCustomerById } from "./woo.js"; import { createWooCustomer, getWooCustomerById } from "./woo.js";
import { llmPlan } from "./openai.js";
function nowIso() { function nowIso() {
@@ -21,6 +22,60 @@ function newId(prefix = "run") {
return `${prefix}_${crypto.randomUUID()}`; return `${prefix}_${crypto.randomUUID()}`;
} }
const PROMPT_SYSTEM = `
SYSTEM PROMPT — Butcher Shop WhatsApp Agent
You are an AI customer support and sales agent for a butcher shop on WhatsApp. Your goals are: help customers clearly, increase sales naturally, and never invent data or actions. You integrate with WooCommerce (products, orders, prices), Postgres (identity and conversation state), RAG (policies, delivery rules), and Mercado Pago (payment links). Never guess information from these systems.
You receive: wa_chat_id, last_user_message, conversation_history, current_conversation_state, woo_customer_id (if known), and optional tool context. You MUST respect current_conversation_state. If missing, assume IDLE.
Respond in Spanish. Be warm, concise, local, trustworthy. No technical language. Never mention internal systems. Max 350 characters unless the user explicitly asks for detail.
Conversation rules
One message = one action only: ask missing info (max 12 questions), confirm an order summary, answer one question, or send payment link + next step. Never ask everything at once. Never repeat the same question or greeting twice. If the user is already ordering, never restart the conversation.
States (output as next_state)
Use ONLY these values: IDLE, BROWSING, BUILDING_ORDER, WAITING_ADDRESS, WAITING_PAYMENT, COMPLETED.
IDLE: greeting or general question.
BROWSING: exploring options/prices.
BUILDING_ORDER: defining or modifying basket.
WAITING_ADDRESS: delivery address needed.
WAITING_PAYMENT: order created, waiting for payment.
COMPLETED: payment confirmed.
Intent (always required)
Intent describes what the user wants, not the step. Allowed values: ask_recommendation, ask_price, browse_products, create_order, add_item, remove_item, checkout, provide_address, confirm_payment, track_order, other. Use the strongest applicable intent and never downgrade it.
Product & basket resolution (MANDATORY)
The shop sells by weight as WooCommerce products/variations or by unit depending of the product.
When the user is ordering and you mention products, you MUST resolve each item to a real WooCommerce product_id (and variation_id if needed) using product lookup tools (e.g. getManyProducts).
If you cannot confidently resolve an ID, do NOT create/update/checkout the order. Ask one clarifying question instead.
basket_resolved (MANDATORY)
When ordering, you MUST output basket_resolved.items with resolved IDs.
Each item must include: product_id (int), variation_id (int or null), quantity (number), unit (kg|g|unit), label (string).
If the user is not ordering, basket_resolved.items must be an empty array. Never leave it empty if you referenced items during an order.
Order & payment rules
Only WooCommerce tools can create/update/cancel orders. Never claim an order exists without a real order_id from tools.
If an order is created, a Mercado Pago payment link MUST be generated and sent immediately. Never invent order numbers or links. If tools have not run yet, send a short neutral “hold” message and signal the action via order_action.
Output format (STRICT)
You MUST output ONLY valid JSON, no extra text. Required keys every time: reply, next_state, intent, missing_fields, order_action, basket_resolved.
order_action ∈ [none, create, update, cancel, checkout].
missing_fields is an array (empty if none).
additionalProperties = false.
Before returning, double-check: did I include all required keys? Did I move the conversation one step forward without repeating myself?
`.trim();
export async function processMessage({ export async function processMessage({
tenantId, tenantId,
chat_id, chat_id,
@@ -68,7 +123,36 @@ export async function processMessage({
}); });
mark("after_insertMessage_in"); mark("after_insertMessage_in");
const plan = { // Contexto para LLM (historial compacto)
const history = await getRecentMessagesForLLM({
tenant_id: tenantId,
wa_chat_id: chat_id,
limit: 20,
});
const compactHistory = collapseAssistantMessages(history);
mark("after_getRecentMessagesForLLM_for_plan");
const llmInput = {
wa_chat_id: chat_id,
last_user_message: text,
conversation_history: compactHistory,
current_conversation_state: prev_state,
context: {
...(prev?.context || {}),
external_customer_id: externalCustomerId ?? prev?.context?.external_customer_id ?? null,
},
};
let plan;
let llmMeta = null;
mark("before_llmPlan");
try {
const out = await llmPlan({ promptSystem: PROMPT_SYSTEM, input: llmInput });
plan = out.plan;
llmMeta = { model: out.model, usage: out.usage, raw_text: out.raw_text };
} catch (err) {
// Fallback: seguimos sin romper el flujo si no hay key o falla el modelo.
plan = {
reply: `Recibido: "${text}". ¿Querés retiro o envío?`, reply: `Recibido: "${text}". ¿Querés retiro o envío?`,
next_state: "BUILDING_ORDER", next_state: "BUILDING_ORDER",
intent: "create_order", intent: "create_order",
@@ -76,6 +160,10 @@ export async function processMessage({
order_action: "none", order_action: "none",
basket_resolved: { items: [] }, basket_resolved: { items: [] },
}; };
llmMeta = { error: String(err?.message || err), code: err?.code || null };
}
mark("after_llmPlan");
const runStatus = llmMeta?.error ? "warn" : "ok";
const invariants = { const invariants = {
ok: true, ok: true,
@@ -93,11 +181,11 @@ export async function processMessage({
message_id: `${provider}:${message_id}`, message_id: `${provider}:${message_id}`,
prev_state, prev_state,
user_text: text, user_text: text,
llm_output: plan, llm_output: { ...plan, _llm: llmMeta },
tools: [], tools: [],
invariants, invariants,
final_reply: plan.reply, final_reply: plan.reply,
status: "ok", status: runStatus,
latency_ms: null, // se actualiza al final con end-to-end real latency_ms: null, // se actualiza al final con end-to-end real
}); });
mark("after_insertRun"); mark("after_insertRun");
@@ -115,6 +203,21 @@ export async function processMessage({
}); });
mark("after_insertMessage_out"); mark("after_insertMessage_out");
// Si hubo error al llamar al LLM (o no hay API key), guardamos una burbuja de error clickeable.
if (llmMeta?.error) {
const errMsgId = newId("err");
await insertMessage({
tenant_id: tenantId,
wa_chat_id: chat_id,
provider: "system",
message_id: errMsgId,
direction: "out",
text: `[ERROR] openai: ${llmMeta.error}`,
payload: { error: { source: "openai", ...llmMeta }, input: llmInput },
run_id,
});
}
mark("before_ensureWooCustomer"); mark("before_ensureWooCustomer");
if (externalCustomerId) { if (externalCustomerId) {
const found = await getWooCustomerById({ tenantId, id: externalCustomerId }); const found = await getWooCustomerById({ tenantId, id: externalCustomerId });
@@ -175,31 +278,11 @@ export async function processMessage({
from: stateRow.wa_chat_id.replace(/^sim:/, ""), from: stateRow.wa_chat_id.replace(/^sim:/, ""),
state: stateRow.state, state: stateRow.state,
intent: stateRow.last_intent || "other", intent: stateRow.last_intent || "other",
status: "ok", status: runStatus,
last_activity: stateRow.updated_at, last_activity: stateRow.updated_at,
last_run_id: run_id, last_run_id: run_id,
}); });
const history = await getRecentMessagesForLLM({
tenant_id: tenantId,
wa_chat_id: chat_id,
limit: 20,
});
const compactHistory = collapseAssistantMessages(history);
mark("after_getRecentMessagesForLLM");
const llmInput = {
wa_chat_id: chat_id,
last_user_message: text,
conversation_history: compactHistory,
current_conversation_state: prev_state,
context: {
...(prev?.context || {}),
external_customer_id: externalCustomerId ?? prev?.context?.external_customer_id ?? null,
},
};
void llmInput;
const end_to_end_ms = Date.now() - started_at; const end_to_end_ms = Date.now() - started_at;
if (run_id) { if (run_id) {
await updateRunLatency({ tenant_id: tenantId, run_id, latency_ms: end_to_end_ms }); await updateRunLatency({ tenant_id: tenantId, run_id, latency_ms: end_to_end_ms });
@@ -210,10 +293,10 @@ export async function processMessage({
ts: nowIso(), ts: nowIso(),
chat_id, chat_id,
from, from,
status: "ok", status: runStatus,
prev_state, prev_state,
input: { text }, input: { text },
llm_output: plan, llm_output: { ...plan, _llm: llmMeta },
tools: [], tools: [],
invariants, invariants,
final_reply: plan.reply, final_reply: plan.reply,
@@ -234,11 +317,12 @@ export async function processMessage({
db_state_ms: msBetween("start", "after_getConversationState"), db_state_ms: msBetween("start", "after_getConversationState"),
db_identity_ms: msBetween("after_getConversationState", "after_getExternalCustomerIdByChat"), db_identity_ms: msBetween("after_getConversationState", "after_getExternalCustomerIdByChat"),
insert_in_ms: msBetween("after_getExternalCustomerIdByChat", "after_insertMessage_in"), insert_in_ms: msBetween("after_getExternalCustomerIdByChat", "after_insertMessage_in"),
history_for_plan_ms: msBetween("after_insertMessage_in", "after_getRecentMessagesForLLM_for_plan"),
llm_ms: msBetween("before_llmPlan", "after_llmPlan"),
insert_run_ms: msBetween("before_insertRun", "after_insertRun"), insert_run_ms: msBetween("before_insertRun", "after_insertRun"),
insert_out_ms: msBetween("after_insertRun", "after_insertMessage_out"), insert_out_ms: msBetween("after_insertRun", "after_insertMessage_out"),
woo_customer_ms: msBetween("before_ensureWooCustomer", "after_ensureWooCustomer"), woo_customer_ms: msBetween("before_ensureWooCustomer", "after_ensureWooCustomer"),
upsert_state_ms: msBetween("after_ensureWooCustomer", "after_upsertConversationState"), upsert_state_ms: msBetween("after_ensureWooCustomer", "after_upsertConversationState"),
history_ms: msBetween("after_upsertConversationState", "after_getRecentMessagesForLLM"),
}, },
}); });

203
src/services/wooProducts.js Normal file
View File

@@ -0,0 +1,203 @@
import { getDecryptedTenantEcommerceConfig, getWooProductCacheById, searchWooProductCache, upsertWooProductCache } from "../db/repo.js";
async function fetchWoo({ url, method = "GET", body = null, timeout = 8000, headers = {} }) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new Error("timeout")), timeout);
const t0 = Date.now();
try {
const res = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
...headers,
},
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
const text = await res.text();
let parsed;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
if (!res.ok) {
const err = new Error(`Woo HTTP ${res.status}`);
err.status = res.status;
err.body = parsed;
err.url = url;
err.method = method;
throw err;
}
return parsed;
} catch (e) {
const err = new Error(`Woo request failed after ${Date.now() - t0}ms: ${e.message}`);
err.cause = e;
throw err;
} finally {
clearTimeout(timer);
}
}
async function getWooClient({ tenantId }) {
const encryptionKey = process.env.APP_ENCRYPTION_KEY;
if (!encryptionKey) throw new Error("APP_ENCRYPTION_KEY is required to decrypt Woo credentials");
const cfg = await getDecryptedTenantEcommerceConfig({
tenant_id: tenantId,
provider: "woo",
encryption_key: encryptionKey,
});
if (!cfg) throw new Error("Woo config not found for tenant");
const consumerKey =
cfg.consumer_key ||
process.env.WOO_CONSUMER_KEY ||
(() => {
throw new Error("consumer_key not set");
})();
const consumerSecret =
cfg.consumer_secret ||
process.env.WOO_CONSUMER_SECRET ||
(() => {
throw new Error("consumer_secret not set");
})();
const base = cfg.base_url.replace(/\/+$/, "");
const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString("base64");
return {
base,
authHeader: { Authorization: `Basic ${auth}` },
timeout: Math.max(cfg.timeout_ms ?? 8000, 2000),
};
}
function parsePrice(p) {
if (p == null) return null;
const n = Number(String(p).replace(",", "."));
return Number.isFinite(n) ? n : null;
}
function isStale(refreshed_at, maxAgeMs) {
if (!refreshed_at) return true;
const t = new Date(refreshed_at).getTime();
if (!Number.isFinite(t)) return true;
return Date.now() - t > maxAgeMs;
}
function normalizeWooProduct(p) {
return {
woo_product_id: p?.id,
name: p?.name || "",
sku: p?.sku || null,
price: parsePrice(p?.price ?? p?.regular_price ?? p?.sale_price),
currency: null,
payload: p,
};
}
export async function searchProducts({
tenantId,
q,
limit = 10,
maxAgeMs = 24 * 60 * 60 * 1000,
forceWoo = false,
}) {
const lim = Math.max(1, Math.min(50, parseInt(limit, 10) || 10));
const query = String(q || "").trim();
if (!query) return { items: [], source: "none" };
// 1) Cache en Postgres
const cached = await searchWooProductCache({ tenant_id: tenantId, q: query, limit: lim });
// 2) Si no hay nada (o force), buscamos en Woo y cacheamos
const needWooSearch = forceWoo || cached.length === 0;
const client = await getWooClient({ tenantId });
let wooItems = [];
if (needWooSearch) {
const url = `${client.base}/products?search=${encodeURIComponent(query)}&per_page=${lim}`;
const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
wooItems = Array.isArray(data) ? data : [];
for (const p of wooItems) {
const n = normalizeWooProduct(p);
if (!n.woo_product_id || !n.name) continue;
await upsertWooProductCache({
tenant_id: tenantId,
woo_product_id: n.woo_product_id,
name: n.name,
sku: n.sku,
price: n.price,
currency: n.currency,
payload: n.payload,
refreshed_at: new Date().toISOString(),
});
}
}
// 3) Si tenemos cache pero está stale, refrescamos precio contra Woo (por ID) y actualizamos si cambió.
// Nota: lo hacemos solo para los items que vamos a devolver (lim), para no demorar demasiado.
const toReturn = cached.slice(0, lim);
for (const c of toReturn) {
if (!isStale(c.refreshed_at, maxAgeMs)) continue;
try {
const url = `${client.base}/products/${encodeURIComponent(c.woo_product_id)}`;
const p = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
const n = normalizeWooProduct(p);
if (!n.woo_product_id || !n.name) continue;
// Si cambió el precio (o faltaba), actualizamos.
const prevPrice = c.price == null ? null : Number(c.price);
const nextPrice = n.price;
const changed = prevPrice !== nextPrice;
await upsertWooProductCache({
tenant_id: tenantId,
woo_product_id: n.woo_product_id,
name: n.name,
sku: n.sku,
price: n.price,
currency: n.currency,
payload: n.payload,
refreshed_at: new Date().toISOString(),
});
if (changed) {
// mantener coherencia de respuesta: refrescar item desde DB
const refreshed = await getWooProductCacheById({ tenant_id: tenantId, woo_product_id: n.woo_product_id });
if (refreshed) Object.assign(c, refreshed);
}
} catch {
// si Woo falla, devolvemos cache (mejor que nada)
}
}
// 4) Respuesta “unificada” (preferimos cache, pero si hicimos Woo search devolvemos esos)
const finalItems = needWooSearch
? wooItems
.map(normalizeWooProduct)
.filter((p) => p.woo_product_id && p.name)
.slice(0, lim)
.map((p) => ({
woo_product_id: p.woo_product_id,
name: p.name,
sku: p.sku,
price: p.price,
currency: p.currency,
source: "woo",
}))
: toReturn.map((c) => ({
woo_product_id: c.woo_product_id,
name: c.name,
sku: c.sku,
price: c.price == null ? null : Number(c.price),
currency: c.currency,
refreshed_at: c.refreshed_at,
source: isStale(c.refreshed_at, maxAgeMs) ? "cache_stale" : "cache",
}));
return { items: finalItems, source: needWooSearch ? "woo" : "cache" };
}