mejoras varias en frontend, separacion de intent y state, pick de articulos
This commit is contained in:
48
src/controllers/admin.js
Normal file
48
src/controllers/admin.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { handleDeleteConversation, handleDeleteUser, handleListUsers, handleRetryLast } from "../handlers/admin.js";
|
||||
|
||||
export const makeDeleteConversation = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const result = await handleDeleteConversation({ tenantId, chat_id: req.params.chat_id });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ ok: false, error: "internal_error" });
|
||||
}
|
||||
};
|
||||
|
||||
export const makeListUsers = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const result = await handleListUsers({ tenantId, q: req.query.q || "", limit: req.query.limit || "200" });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ ok: false, error: "internal_error" });
|
||||
}
|
||||
};
|
||||
|
||||
export const makeDeleteUser = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const deleteWoo = String(req.query.deleteWoo || "0") === "1" || String(req.query.deleteWoo || "").toLowerCase() === "true";
|
||||
const result = await handleDeleteUser({ tenantId, chat_id: req.params.chat_id, deleteWoo });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ ok: false, error: "internal_error" });
|
||||
}
|
||||
};
|
||||
|
||||
export const makeRetryLast = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const result = await handleRetryLast({ tenantId, chat_id: req.params.chat_id });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ ok: false, error: "internal_error" });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
156
src/db/repo.js
156
src/db/repo.js
@@ -55,6 +55,22 @@ export async function upsertConversationState({
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// Crea la conversación si no existe y, si existe, solo “toca” updated_at (no pisa state/context).
|
||||
export async function touchConversationState({ tenant_id, wa_chat_id }) {
|
||||
const q = `
|
||||
insert into wa_conversation_state
|
||||
(tenant_id, wa_chat_id, state, state_updated_at, last_intent, last_order_id, context, updated_at)
|
||||
values
|
||||
($1, $2, 'IDLE', now(), 'other', null, '{}'::jsonb, now())
|
||||
on conflict (tenant_id, wa_chat_id)
|
||||
do update set
|
||||
updated_at = now()
|
||||
returning tenant_id, wa_chat_id, state, last_intent, context, updated_at
|
||||
`;
|
||||
const { rows } = await pool.query(q, [tenant_id, wa_chat_id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function insertMessage({
|
||||
tenant_id,
|
||||
wa_chat_id,
|
||||
@@ -299,6 +315,146 @@ export async function listMessages({ tenant_id, wa_chat_id, limit = 200 }) {
|
||||
}));
|
||||
}
|
||||
|
||||
export async function deleteConversationData({ tenant_id, wa_chat_id }) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const r1 = await client.query(`delete from wa_messages where tenant_id=$1 and wa_chat_id=$2`, [
|
||||
tenant_id,
|
||||
wa_chat_id,
|
||||
]);
|
||||
const r2 = await client.query(`delete from conversation_runs where tenant_id=$1 and wa_chat_id=$2`, [
|
||||
tenant_id,
|
||||
wa_chat_id,
|
||||
]);
|
||||
const r3 = await client.query(`delete from wa_conversation_state where tenant_id=$1 and wa_chat_id=$2`, [
|
||||
tenant_id,
|
||||
wa_chat_id,
|
||||
]);
|
||||
|
||||
await client.query("COMMIT");
|
||||
return { ok: true, deleted: { messages: r1.rowCount, runs: r2.rowCount, state: r3.rowCount } };
|
||||
} catch (e) {
|
||||
await client.query("ROLLBACK");
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function listUsers({ tenant_id, q = "", limit = 200 }) {
|
||||
const lim = Math.max(1, Math.min(500, parseInt(limit, 10) || 200));
|
||||
const qstr = String(q || "").trim();
|
||||
|
||||
// Lista de “usuarios” = conversaciones existentes (wa_conversation_state), con join al mapping Woo si existe.
|
||||
// Esto permite ver usuarios aunque nunca se haya creado el customer en Woo.
|
||||
const sql = `
|
||||
select *
|
||||
from (
|
||||
select s.wa_chat_id,
|
||||
'woo' as provider,
|
||||
m.external_customer_id,
|
||||
lastmsg.ts as last_ts,
|
||||
nullif(coalesce(lastmsg.payload #>> '{raw,meta,pushName}', lastmsg.payload #>> '{raw,meta,pushname}', ''), '') as push_name
|
||||
from wa_conversation_state s
|
||||
left join wa_identity_map m
|
||||
on m.tenant_id = s.tenant_id
|
||||
and m.wa_chat_id = s.wa_chat_id
|
||||
and m.provider = 'woo'
|
||||
left join lateral (
|
||||
select ts, payload
|
||||
from wa_messages
|
||||
where tenant_id = s.tenant_id
|
||||
and wa_chat_id = s.wa_chat_id
|
||||
and direction = 'in'
|
||||
order by ts desc
|
||||
limit 1
|
||||
) lastmsg on true
|
||||
where s.tenant_id = $1
|
||||
) t
|
||||
where ($2 = '' or t.wa_chat_id ilike $3 or coalesce(t.push_name,'') ilike $3)
|
||||
order by coalesce(t.last_ts, now()) desc
|
||||
limit $4
|
||||
`;
|
||||
|
||||
const like = qstr ? `%${qstr}%` : "";
|
||||
const { rows } = await pool.query(sql, [tenant_id, qstr, like, lim]);
|
||||
return rows.map((r) => ({
|
||||
chat_id: r.wa_chat_id,
|
||||
provider: r.provider,
|
||||
external_customer_id: r.external_customer_id,
|
||||
push_name: r.push_name || null,
|
||||
last_ts: r.last_ts || null,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getLastInboundMessage({ tenant_id, wa_chat_id }) {
|
||||
const q = `
|
||||
select provider, message_id, ts, text, payload
|
||||
from wa_messages
|
||||
where tenant_id=$1 and wa_chat_id=$2 and direction='in'
|
||||
order by ts desc
|
||||
limit 1
|
||||
`;
|
||||
const { rows } = await pool.query(q, [tenant_id, wa_chat_id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function cleanupLastRunForRetry({ tenant_id, wa_chat_id }) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const { rows } = await client.query(
|
||||
`
|
||||
select id
|
||||
from conversation_runs
|
||||
where tenant_id=$1 and wa_chat_id=$2
|
||||
order by ts desc
|
||||
limit 1
|
||||
`,
|
||||
[tenant_id, wa_chat_id]
|
||||
);
|
||||
const run_id = rows[0]?.id || null;
|
||||
if (!run_id) {
|
||||
await client.query("COMMIT");
|
||||
return { ok: true, run_id: null, deleted_out_messages: 0, deleted_runs: 0 };
|
||||
}
|
||||
|
||||
const r1 = await client.query(
|
||||
`delete from wa_messages where tenant_id=$1 and wa_chat_id=$2 and run_id=$3 and direction='out'`,
|
||||
[tenant_id, wa_chat_id, run_id]
|
||||
);
|
||||
const r2 = await client.query(`delete from conversation_runs where tenant_id=$1 and id=$2`, [tenant_id, run_id]);
|
||||
|
||||
await client.query("COMMIT");
|
||||
return { ok: true, run_id, deleted_out_messages: r1.rowCount || 0, deleted_runs: r2.rowCount || 0 };
|
||||
} catch (e) {
|
||||
await client.query("ROLLBACK");
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getIdentityMapByChat({ tenant_id, wa_chat_id, provider = "woo" }) {
|
||||
const q = `
|
||||
select tenant_id, wa_chat_id, provider, external_customer_id, created_at, updated_at
|
||||
from wa_identity_map
|
||||
where tenant_id=$1 and wa_chat_id=$2 and provider=$3
|
||||
limit 1
|
||||
`;
|
||||
const { rows } = await pool.query(q, [tenant_id, wa_chat_id, provider]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function deleteIdentityMapByChat({ tenant_id, wa_chat_id, provider = "woo" }) {
|
||||
const q = `delete from wa_identity_map where tenant_id=$1 and wa_chat_id=$2 and provider=$3`;
|
||||
const { rowCount } = await pool.query(q, [tenant_id, wa_chat_id, provider]);
|
||||
return rowCount || 0;
|
||||
}
|
||||
|
||||
export async function getTenantByKey(key) {
|
||||
const { rows } = await pool.query(`select id, key, name from tenants where key=$1`, [key]);
|
||||
return rows[0] || null;
|
||||
|
||||
91
src/handlers/admin.js
Normal file
91
src/handlers/admin.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import crypto from "crypto";
|
||||
import {
|
||||
cleanupLastRunForRetry,
|
||||
deleteConversationData,
|
||||
deleteIdentityMapByChat,
|
||||
getIdentityMapByChat,
|
||||
getLastInboundMessage,
|
||||
listUsers,
|
||||
} from "../db/repo.js";
|
||||
import { deleteWooCustomer } from "../services/woo.js";
|
||||
import { processMessage } from "../services/pipeline.js";
|
||||
|
||||
export async function handleDeleteConversation({ tenantId, chat_id }) {
|
||||
if (!chat_id) return { ok: false, error: "chat_id_required" };
|
||||
const result = await deleteConversationData({ tenant_id: tenantId, wa_chat_id: String(chat_id) });
|
||||
return { ok: true, ...result };
|
||||
}
|
||||
|
||||
export async function handleListUsers({ tenantId, q = "", limit = "200" }) {
|
||||
const items = await listUsers({ tenant_id: tenantId, q: String(q || ""), limit: parseInt(limit, 10) || 200 });
|
||||
return { ok: true, items };
|
||||
}
|
||||
|
||||
export async function handleDeleteUser({ tenantId, chat_id, deleteWoo = false }) {
|
||||
const wa_chat_id = String(chat_id || "");
|
||||
if (!wa_chat_id) return { ok: false, error: "chat_id_required" };
|
||||
|
||||
const mapping = await getIdentityMapByChat({ tenant_id: tenantId, wa_chat_id, provider: "woo" });
|
||||
const external_customer_id = mapping?.external_customer_id || null;
|
||||
|
||||
// borrar conversaciones (mensajes + runs + state)
|
||||
const convo = await deleteConversationData({ tenant_id: tenantId, wa_chat_id });
|
||||
|
||||
// borrar mapping
|
||||
const deletedMap = await deleteIdentityMapByChat({ tenant_id: tenantId, wa_chat_id, provider: "woo" });
|
||||
|
||||
// borrar customer en woo (best-effort)
|
||||
let woo = null;
|
||||
if (deleteWoo && external_customer_id) {
|
||||
try {
|
||||
woo = await deleteWooCustomer({ tenantId, id: external_customer_id, force: true });
|
||||
} catch (e) {
|
||||
woo = { ok: false, error: String(e?.message || e), status: e?.status || e?.cause?.status || null };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
chat_id: wa_chat_id,
|
||||
external_customer_id,
|
||||
deleted: {
|
||||
conversations: convo?.deleted || null,
|
||||
identity_map: deletedMap,
|
||||
woo,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleRetryLast({ tenantId, chat_id }) {
|
||||
const wa_chat_id = String(chat_id || "");
|
||||
if (!wa_chat_id) return { ok: false, error: "chat_id_required" };
|
||||
|
||||
const lastIn = await getLastInboundMessage({ tenant_id: tenantId, wa_chat_id });
|
||||
if (!lastIn) return { ok: false, error: "no_inbound_message" };
|
||||
|
||||
// 1) borrar la última respuesta/run
|
||||
const cleanup = await cleanupLastRunForRetry({ tenant_id: tenantId, wa_chat_id });
|
||||
|
||||
// 2) reinyectar el último mensaje del usuario como un nuevo message_id
|
||||
const raw = lastIn.payload?.raw || {};
|
||||
const text = String(lastIn.text || raw.text || "").trim();
|
||||
if (!text) return { ok: false, error: "last_inbound_text_empty", cleanup };
|
||||
|
||||
const from = String(raw.from || wa_chat_id.replace(/@.+$/, ""));
|
||||
const pushName = raw?.meta?.pushName || raw?.meta?.pushname || null;
|
||||
|
||||
const pm = await processMessage({
|
||||
tenantId,
|
||||
chat_id: wa_chat_id,
|
||||
from,
|
||||
text,
|
||||
provider: lastIn.provider || "evolution",
|
||||
message_id: crypto.randomUUID(),
|
||||
displayName: pushName,
|
||||
meta: { ...(raw.meta || {}), source: "retry_last" },
|
||||
});
|
||||
|
||||
return { ok: true, cleanup, run_id: pm?.run_id || null };
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function handleEvolutionWebhook(body) {
|
||||
text: parsed.text,
|
||||
provider: "evolution",
|
||||
message_id: parsed.message_id || crypto.randomUUID(),
|
||||
meta: { pushName: parsed.from_name, ts: parsed.ts, instance: parsed.tenant_key },
|
||||
meta: { pushName: parsed.from_name, ts: parsed.ts, instance: parsed.tenant_key, source: parsed.source },
|
||||
});
|
||||
|
||||
console.log("[perf] evolution.webhook.end", {
|
||||
|
||||
@@ -40,6 +40,7 @@ export function parseEvolutionWebhook(reqBody) {
|
||||
// metadata
|
||||
const pushName = data.pushName || null;
|
||||
const ts = data.messageTimestamp ? new Date(Number(data.messageTimestamp) * 1000).toISOString() : null;
|
||||
const source = data.source || null; // e.g. "sim"
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
@@ -50,6 +51,7 @@ export function parseEvolutionWebhook(reqBody) {
|
||||
from_name: pushName,
|
||||
message_type: messageType || null,
|
||||
ts,
|
||||
source,
|
||||
raw: body, // para log/debug si querés
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
import OpenAI from "openai";
|
||||
import { z } from "zod";
|
||||
|
||||
const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY;
|
||||
let _client = null;
|
||||
let _clientKey = null;
|
||||
|
||||
export const openai = new OpenAI({ apiKey });
|
||||
function getApiKey() {
|
||||
return process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY || null;
|
||||
}
|
||||
|
||||
function getClient() {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
const err = new Error("OPENAI_API_KEY is not set");
|
||||
err.code = "OPENAI_NO_KEY";
|
||||
throw err;
|
||||
}
|
||||
if (_client && _clientKey === apiKey) return _client;
|
||||
_clientKey = apiKey;
|
||||
_client = new OpenAI({ apiKey });
|
||||
return _client;
|
||||
}
|
||||
|
||||
const NextStateSchema = z.enum([
|
||||
"IDLE",
|
||||
@@ -53,6 +69,19 @@ const PlanSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ExtractItemSchema = z.object({
|
||||
label: z.string().min(1),
|
||||
quantity: z.number().positive(),
|
||||
unit: z.enum(["kg", "g", "unit"]),
|
||||
});
|
||||
|
||||
const ExtractSchema = z
|
||||
.object({
|
||||
intent: IntentSchema,
|
||||
items: z.array(ExtractItemSchema).default([]),
|
||||
})
|
||||
.strict();
|
||||
|
||||
function extractJsonObject(text) {
|
||||
const s = String(text || "");
|
||||
const i = s.indexOf("{");
|
||||
@@ -61,36 +90,29 @@ function extractJsonObject(text) {
|
||||
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;
|
||||
}
|
||||
|
||||
async function jsonCompletion({ system, user, model }) {
|
||||
const openai = getClient();
|
||||
const chosenModel = model || process.env.OPENAI_MODEL || "gpt-4o-mini";
|
||||
const debug = String(process.env.LLM_DEBUG || "") === "1";
|
||||
if (debug) console.log("[llm] openai.request", { model: chosenModel });
|
||||
|
||||
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 ?? {}) },
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: user },
|
||||
],
|
||||
});
|
||||
|
||||
if (debug)
|
||||
console.log("[llm] openai.response", {
|
||||
id: resp?.id || null,
|
||||
model: resp?.model || null,
|
||||
usage: resp?.usage || null,
|
||||
});
|
||||
|
||||
const text = resp?.choices?.[0]?.message?.content || "";
|
||||
let parsed;
|
||||
try {
|
||||
@@ -100,12 +122,51 @@ export async function llmPlan({ promptSystem, input, model } = {}) {
|
||||
if (!extracted) throw new Error("openai_invalid_json");
|
||||
parsed = JSON.parse(extracted);
|
||||
}
|
||||
return { parsed, raw_text: text, model: chosenModel, usage: resp?.usage || 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 } = {}) {
|
||||
const system =
|
||||
`${promptSystem}\n\n` +
|
||||
"Respondé SOLO con un JSON válido (sin markdown). Respetá estrictamente el formato requerido.";
|
||||
const { parsed, raw_text, model: chosenModel, usage } = await jsonCompletion({
|
||||
system,
|
||||
user: JSON.stringify(input ?? {}),
|
||||
model,
|
||||
});
|
||||
|
||||
const plan = PlanSchema.parse(parsed);
|
||||
return {
|
||||
plan,
|
||||
raw_text: text,
|
||||
raw_text,
|
||||
model: chosenModel,
|
||||
usage: resp?.usage || null,
|
||||
usage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Paso 1: extracción de intención + items mencionados (sin resolver IDs).
|
||||
* Devuelve SOLO: intent + items[{label, quantity, unit}]
|
||||
*/
|
||||
export async function llmExtract({ input, model } = {}) {
|
||||
const system =
|
||||
"Extraé intención e items del mensaje del usuario.\n" +
|
||||
"Respondé SOLO JSON válido (sin markdown) con keys EXACTAS:\n" +
|
||||
`intent (one of: ${IntentSchema.options.join("|")}), items (array of {label, quantity, unit(kg|g|unit)}).\n` +
|
||||
"Si no hay items claros, devolvé items: [].";
|
||||
|
||||
const { parsed, raw_text, model: chosenModel, usage } = await jsonCompletion({
|
||||
system,
|
||||
user: JSON.stringify(input ?? {}),
|
||||
model,
|
||||
});
|
||||
|
||||
const extracted = ExtractSchema.parse(parsed);
|
||||
return { extracted, raw_text, model: chosenModel, usage };
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -377,3 +377,42 @@ export async function getWooCustomerById({ tenantId, id }) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWooCustomer({ tenantId, id, force = true }) {
|
||||
if (!id) return { ok: false, error: "missing_id" };
|
||||
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");
|
||||
const timeout = Math.max(cfg.timeout_ms ?? 20000, 20000);
|
||||
|
||||
const url = `${base}/customers/${encodeURIComponent(id)}${force ? "?force=true" : ""}`;
|
||||
const data = await fetchWoo({
|
||||
url,
|
||||
method: "DELETE",
|
||||
timeout,
|
||||
headers: { Authorization: `Basic ${auth}` },
|
||||
});
|
||||
return { ok: true, raw: data };
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,19 @@ function normalizeWooProduct(p) {
|
||||
sku: p?.sku || null,
|
||||
price: parsePrice(p?.price ?? p?.regular_price ?? p?.sale_price),
|
||||
currency: null,
|
||||
type: p?.type || null, // simple | variable | grouped | external
|
||||
attributes: Array.isArray(p?.attributes)
|
||||
? p.attributes.map((a) => ({
|
||||
name: a?.name || null,
|
||||
options: Array.isArray(a?.options) ? a.options.slice(0, 20) : [],
|
||||
}))
|
||||
: [],
|
||||
raw_price: {
|
||||
price: p?.price ?? null,
|
||||
regular_price: p?.regular_price ?? null,
|
||||
sale_price: p?.sale_price ?? null,
|
||||
price_html: p?.price_html ?? null,
|
||||
},
|
||||
payload: p,
|
||||
};
|
||||
}
|
||||
@@ -104,6 +117,7 @@ export async function searchProducts({
|
||||
maxAgeMs = 24 * 60 * 60 * 1000,
|
||||
forceWoo = false,
|
||||
}) {
|
||||
const debug = String(process.env.WOO_PRODUCTS_DEBUG || "") === "1";
|
||||
const lim = Math.max(1, Math.min(50, parseInt(limit, 10) || 10));
|
||||
const query = String(q || "").trim();
|
||||
if (!query) return { items: [], source: "none" };
|
||||
@@ -121,6 +135,22 @@ export async function searchProducts({
|
||||
const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
|
||||
wooItems = Array.isArray(data) ? data : [];
|
||||
|
||||
if (debug) {
|
||||
console.log("[wooProducts] search", {
|
||||
tenantId,
|
||||
query,
|
||||
count: wooItems.length,
|
||||
sample: wooItems.slice(0, 5).map((p) => ({
|
||||
id: p?.id,
|
||||
name: p?.name,
|
||||
sku: p?.sku,
|
||||
price: p?.price,
|
||||
regular_price: p?.regular_price,
|
||||
sale_price: p?.sale_price,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
for (const p of wooItems) {
|
||||
const n = normalizeWooProduct(p);
|
||||
if (!n.woo_product_id || !n.name) continue;
|
||||
@@ -145,6 +175,17 @@ export async function searchProducts({
|
||||
try {
|
||||
const url = `${client.base}/products/${encodeURIComponent(c.woo_product_id)}`;
|
||||
const p = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
|
||||
if (debug) {
|
||||
console.log("[wooProducts] refresh", {
|
||||
tenantId,
|
||||
woo_product_id: c.woo_product_id,
|
||||
name: p?.name,
|
||||
sku: p?.sku,
|
||||
price: p?.price,
|
||||
regular_price: p?.regular_price,
|
||||
sale_price: p?.sale_price,
|
||||
});
|
||||
}
|
||||
const n = normalizeWooProduct(p);
|
||||
if (!n.woo_product_id || !n.name) continue;
|
||||
// Si cambió el precio (o faltaba), actualizamos.
|
||||
@@ -185,6 +226,9 @@ export async function searchProducts({
|
||||
sku: p.sku,
|
||||
price: p.price,
|
||||
currency: p.currency,
|
||||
type: p.type,
|
||||
attributes: p.attributes,
|
||||
raw_price: p.raw_price,
|
||||
source: "woo",
|
||||
}))
|
||||
: toReturn.map((c) => ({
|
||||
@@ -194,6 +238,19 @@ export async function searchProducts({
|
||||
price: c.price == null ? null : Number(c.price),
|
||||
currency: c.currency,
|
||||
refreshed_at: c.refreshed_at,
|
||||
type: c?.payload?.type || null,
|
||||
attributes: Array.isArray(c?.payload?.attributes)
|
||||
? c.payload.attributes.map((a) => ({
|
||||
name: a?.name || null,
|
||||
options: Array.isArray(a?.options) ? a.options.slice(0, 20) : [],
|
||||
}))
|
||||
: [],
|
||||
raw_price: {
|
||||
price: c?.payload?.price ?? null,
|
||||
regular_price: c?.payload?.regular_price ?? null,
|
||||
sale_price: c?.payload?.sale_price ?? null,
|
||||
price_html: c?.payload?.price_html ?? null,
|
||||
},
|
||||
source: isStale(c.refreshed_at, maxAgeMs) ? "cache_stale" : "cache",
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user