mejoras varias en frontend, separacion de intent y state, pick de articulos

This commit is contained in:
Lucas Tettamanti
2026-01-06 15:50:02 -03:00
parent dab52492b4
commit 8bb21b4edb
17 changed files with 1826 additions and 209 deletions

48
src/controllers/admin.js Normal file
View 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" });
}
};

View File

@@ -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
View 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 };
}

View File

@@ -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", {

View File

@@ -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
};
}

View File

@@ -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

View File

@@ -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 };
}

View File

@@ -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",
}));