separated in modules
This commit is contained in:
12
src/modules/1-intake/controllers/evolution.js
Normal file
12
src/modules/1-intake/controllers/evolution.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { handleEvolutionWebhook } from "../handlers/evolution.js";
|
||||
|
||||
export const makeEvolutionWebhook = () => async (req, res) => {
|
||||
try {
|
||||
const result = await handleEvolutionWebhook(req.body || {});
|
||||
res.status(result.status).json(result.payload);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(200).json({ ok: true, error: "internal_error" });
|
||||
}
|
||||
};
|
||||
|
||||
12
src/modules/1-intake/controllers/sim.js
Normal file
12
src/modules/1-intake/controllers/sim.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { handleSimSend } from "../handlers/sim.js";
|
||||
|
||||
export const makeSimSend = () => async (req, res) => {
|
||||
try {
|
||||
const result = await handleSimSend(req.body || {});
|
||||
res.status(result.status).json(result.payload);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ ok: false, error: "internal_error", detail: String(err?.message || err) });
|
||||
}
|
||||
};
|
||||
|
||||
51
src/modules/1-intake/handlers/evolution.js
Normal file
51
src/modules/1-intake/handlers/evolution.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import crypto from "crypto";
|
||||
import { parseEvolutionWebhook } from "../services/evolutionParser.js";
|
||||
import { resolveTenantId, processMessage } from "../../2-identity/services/pipeline.js";
|
||||
import { debug as dbg } from "../../shared/debug.js";
|
||||
|
||||
export async function handleEvolutionWebhook(body) {
|
||||
const t0 = Date.now();
|
||||
const parsed = parseEvolutionWebhook(body);
|
||||
if (!parsed.ok) {
|
||||
return { status: 200, payload: { ok: true, ignored: parsed.reason } };
|
||||
}
|
||||
|
||||
if (dbg.perf || dbg.evolution) {
|
||||
console.log("[perf] evolution.webhook.start", {
|
||||
tenant_key: parsed.tenant_key || null,
|
||||
chat_id: parsed.chat_id,
|
||||
message_id: parsed.message_id || null,
|
||||
ts: parsed.ts || null,
|
||||
});
|
||||
}
|
||||
|
||||
const tenantId = await resolveTenantId({
|
||||
chat_id: parsed.chat_id,
|
||||
tenant_key: parsed.tenant_key,
|
||||
to_phone: null,
|
||||
});
|
||||
|
||||
const pm = await processMessage({
|
||||
tenantId,
|
||||
chat_id: parsed.chat_id,
|
||||
from: parsed.chat_id.replace("@s.whatsapp.net", ""),
|
||||
displayName: parsed.from_name || null,
|
||||
text: parsed.text,
|
||||
provider: "evolution",
|
||||
message_id: parsed.message_id || crypto.randomUUID(),
|
||||
meta: { pushName: parsed.from_name, ts: parsed.ts, instance: parsed.tenant_key, source: parsed.source },
|
||||
});
|
||||
|
||||
if (dbg.perf || dbg.evolution) {
|
||||
console.log("[perf] evolution.webhook.end", {
|
||||
tenantId,
|
||||
chat_id: parsed.chat_id,
|
||||
message_id: parsed.message_id || null,
|
||||
run_id: pm?.run_id || null,
|
||||
webhook_ms: Date.now() - t0,
|
||||
});
|
||||
}
|
||||
|
||||
return { status: 200, payload: { ok: true } };
|
||||
}
|
||||
|
||||
30
src/modules/1-intake/handlers/sim.js
Normal file
30
src/modules/1-intake/handlers/sim.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import crypto from "crypto";
|
||||
import { resolveTenantId } from "../../2-identity/services/pipeline.js";
|
||||
import { processMessage } from "../../2-identity/services/pipeline.js";
|
||||
|
||||
export async function handleSimSend(body) {
|
||||
const { chat_id, from_phone, text } = body || {};
|
||||
if (!chat_id || !from_phone || !text) {
|
||||
return { status: 400, payload: { ok: false, error: "chat_id, from_phone, text are required" } };
|
||||
}
|
||||
|
||||
const provider = "sim";
|
||||
const message_id = crypto.randomUUID();
|
||||
const tenantId = await resolveTenantId({
|
||||
chat_id,
|
||||
tenant_key: body?.tenant_key,
|
||||
to_phone: body?.to_phone,
|
||||
});
|
||||
|
||||
const result = await processMessage({
|
||||
tenantId,
|
||||
chat_id,
|
||||
from: from_phone,
|
||||
text,
|
||||
provider,
|
||||
message_id,
|
||||
});
|
||||
|
||||
return { status: 200, payload: { ok: true, run_id: result.run_id, reply: result.reply } };
|
||||
}
|
||||
|
||||
13
src/modules/1-intake/routes/evolution.js
Normal file
13
src/modules/1-intake/routes/evolution.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import express from "express";
|
||||
import { makeEvolutionWebhook } from "../controllers/evolution.js";
|
||||
|
||||
/**
|
||||
* Integración Evolution (webhook).
|
||||
* Mantiene exactamente el mismo path que existía en `index.js`.
|
||||
*/
|
||||
export function createEvolutionRouter() {
|
||||
const router = express.Router();
|
||||
router.post("/webhook/evolution", makeEvolutionWebhook());
|
||||
return router;
|
||||
}
|
||||
|
||||
61
src/modules/1-intake/routes/simulator.js
Normal file
61
src/modules/1-intake/routes/simulator.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import express from "express";
|
||||
|
||||
import { addSseClient, removeSseClient } from "../../shared/sse.js";
|
||||
import { makeGetConversations } from "../../../controllers/conversations.js";
|
||||
import { makeListRuns, makeGetRunById } from "../../../controllers/runs.js";
|
||||
import { makeSimSend } from "../controllers/sim.js";
|
||||
import { makeGetConversationState } from "../../../controllers/conversationState.js";
|
||||
import { makeListMessages } from "../../../controllers/messages.js";
|
||||
import { makeSearchProducts } from "../../../controllers/products.js";
|
||||
import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../../controllers/admin.js";
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoints consumidos por el frontend (UI / simulador).
|
||||
* Mantiene exactamente los mismos paths que existían en `index.js`.
|
||||
*/
|
||||
export function createSimulatorRouter({ tenantId }) {
|
||||
const router = express.Router();
|
||||
const getTenantId = () => tenantId;
|
||||
|
||||
/**
|
||||
* --- SSE endpoint ---
|
||||
*/
|
||||
router.get("/stream", (req, res) => {
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.flushHeaders?.();
|
||||
|
||||
res.write(`event: hello\ndata: ${JSON.stringify({ ts: nowIso() })}\n\n`);
|
||||
addSseClient(res);
|
||||
|
||||
req.on("close", () => {
|
||||
removeSseClient(res);
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* --- UI data endpoints ---
|
||||
*/
|
||||
router.post("/sim/send", makeSimSend());
|
||||
|
||||
router.get("/conversations", makeGetConversations(getTenantId));
|
||||
router.get("/conversations/state", makeGetConversationState(getTenantId));
|
||||
router.delete("/conversations/:chat_id", makeDeleteConversation(getTenantId));
|
||||
router.post("/conversations/:chat_id/retry-last", makeRetryLast(getTenantId));
|
||||
router.get("/messages", makeListMessages(getTenantId));
|
||||
router.get("/products", makeSearchProducts(getTenantId));
|
||||
router.get("/users", makeListUsers(getTenantId));
|
||||
router.delete("/users/:chat_id", makeDeleteUser(getTenantId));
|
||||
|
||||
router.get("/runs", makeListRuns(getTenantId));
|
||||
router.get("/runs/:run_id", makeGetRunById(getTenantId));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
58
src/modules/1-intake/services/evolutionParser.js
Normal file
58
src/modules/1-intake/services/evolutionParser.js
Normal file
@@ -0,0 +1,58 @@
|
||||
export function parseEvolutionWebhook(reqBody) {
|
||||
const envelope = Array.isArray(reqBody) ? reqBody[0] : reqBody;
|
||||
const body = envelope?.body ?? envelope ?? {};
|
||||
|
||||
const event = body.event;
|
||||
const instance = body.instance; // tenant key
|
||||
const data = body.data;
|
||||
|
||||
if (!event || !data || !data.key) {
|
||||
return { ok: false, reason: "missing_fields" };
|
||||
}
|
||||
|
||||
if (event !== "messages.upsert") {
|
||||
return { ok: false, reason: "not_messages_upsert" };
|
||||
}
|
||||
|
||||
const remoteJid = data.key.remoteJid;
|
||||
const fromMe = data.key.fromMe === true;
|
||||
const messageId = data.key.id;
|
||||
|
||||
// only inbound
|
||||
if (fromMe) return { ok: false, reason: "from_me" };
|
||||
|
||||
// ignore groups / broadcasts
|
||||
if (!remoteJid || typeof remoteJid !== "string") return { ok: false, reason: "no_remoteJid" };
|
||||
if (!remoteJid.endsWith("@s.whatsapp.net")) return { ok: false, reason: "not_direct_chat" };
|
||||
|
||||
const messageType = data.messageType;
|
||||
|
||||
// extract text
|
||||
const msg = data.message || {};
|
||||
const text =
|
||||
(typeof msg.conversation === "string" && msg.conversation) ||
|
||||
(typeof msg.extendedTextMessage?.text === "string" && msg.extendedTextMessage.text) ||
|
||||
"";
|
||||
|
||||
const cleanText = String(text).trim();
|
||||
if (!cleanText) return { ok: false, reason: "empty_text" };
|
||||
|
||||
// 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,
|
||||
tenant_key: instance || null,
|
||||
chat_id: remoteJid,
|
||||
message_id: messageId || null,
|
||||
text: cleanText,
|
||||
from_name: pushName,
|
||||
message_type: messageType || null,
|
||||
ts,
|
||||
source,
|
||||
raw: body, // para log/debug si querés
|
||||
};
|
||||
}
|
||||
|
||||
59
src/modules/2-identity/controllers/wooWebhooks.js
Normal file
59
src/modules/2-identity/controllers/wooWebhooks.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { refreshProductByWooId } from "../../shared/wooSnapshot.js";
|
||||
import { getTenantByKey } from "../db/repo.js";
|
||||
|
||||
function unauthorized(res) {
|
||||
res.setHeader("WWW-Authenticate", 'Basic realm="woo-webhook"');
|
||||
return res.status(401).json({ ok: false, error: "unauthorized" });
|
||||
}
|
||||
|
||||
function checkBasicAuth(req) {
|
||||
const user = process.env.WOO_WEBHOOK_USER || "";
|
||||
const pass = process.env.WOO_WEBHOOK_PASS || "";
|
||||
const auth = req.headers?.authorization || "";
|
||||
if (!user || !pass) return { ok: false, reason: "missing_env" };
|
||||
if (!auth.startsWith("Basic ")) return { ok: false, reason: "missing_basic" };
|
||||
const decoded = Buffer.from(auth.slice(6), "base64").toString("utf8");
|
||||
const [u, p] = decoded.split(":");
|
||||
if (u === user && p === pass) return { ok: true };
|
||||
return { ok: false, reason: "invalid_creds" };
|
||||
}
|
||||
|
||||
function parseWooId(payload) {
|
||||
const id = payload?.id || payload?.data?.id || null;
|
||||
const parentId = payload?.parent_id || payload?.data?.parent_id || null;
|
||||
const resource = payload?.resource || payload?.topic || null;
|
||||
return { id: id ? Number(id) : null, parentId: parentId ? Number(parentId) : null, resource };
|
||||
}
|
||||
|
||||
export function makeWooProductWebhook() {
|
||||
return async function handleWooProductWebhook(req, res) {
|
||||
const auth = checkBasicAuth(req);
|
||||
if (!auth.ok) return unauthorized(res);
|
||||
|
||||
const { id, parentId, resource } = parseWooId(req.body || {});
|
||||
if (!id) return res.status(400).json({ ok: false, error: "missing_id" });
|
||||
|
||||
// Determinar tenant por query ?tenant_key=...
|
||||
const tenantKey = req.query?.tenant_key || process.env.TENANT_KEY || null;
|
||||
if (!tenantKey) return res.status(400).json({ ok: false, error: "missing_tenant_key" });
|
||||
const tenant = await getTenantByKey(String(tenantKey).toLowerCase());
|
||||
if (!tenant?.id) return res.status(404).json({ ok: false, error: "tenant_not_found" });
|
||||
|
||||
const parentForVariation =
|
||||
resource && String(resource).includes("variation") ? parentId || null : null;
|
||||
|
||||
const updated = await refreshProductByWooId({
|
||||
tenantId: tenant.id,
|
||||
wooId: id,
|
||||
parentId: parentForVariation,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
ok: true,
|
||||
woo_id: updated?.woo_id || id,
|
||||
type: updated?.type || null,
|
||||
parent_id: updated?.parent_id || null,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
15
src/modules/2-identity/db/pool.js
Normal file
15
src/modules/2-identity/db/pool.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import pg from "pg";
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
max: parseInt(process.env.PG_POOL_MAX || "10", 10),
|
||||
idleTimeoutMillis: parseInt(process.env.PG_IDLE_TIMEOUT_MS || "30000", 10),
|
||||
connectionTimeoutMillis: parseInt(process.env.PG_CONN_TIMEOUT_MS || "5000", 10),
|
||||
});
|
||||
|
||||
pool.on("error", (err) => {
|
||||
console.error("[pg pool] unexpected error:", err);
|
||||
});
|
||||
|
||||
648
src/modules/2-identity/db/repo.js
Normal file
648
src/modules/2-identity/db/repo.js
Normal file
@@ -0,0 +1,648 @@
|
||||
import { pool } from "./pool.js";
|
||||
|
||||
export async function ensureTenant({ key, name }) {
|
||||
const q = `
|
||||
insert into tenants (key, name)
|
||||
values ($1, $2)
|
||||
on conflict (key) do update set name = excluded.name
|
||||
returning id
|
||||
`;
|
||||
const { rows } = await pool.query(q, [key, name]);
|
||||
return rows[0].id;
|
||||
}
|
||||
|
||||
export async function getConversationState(tenant_id, wa_chat_id) {
|
||||
const q = `
|
||||
select tenant_id, wa_chat_id, state, last_intent, last_order_id, context, state_updated_at
|
||||
from wa_conversation_state
|
||||
where tenant_id=$1 and wa_chat_id=$2
|
||||
`;
|
||||
const { rows } = await pool.query(q, [tenant_id, wa_chat_id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function upsertConversationState({
|
||||
tenant_id,
|
||||
wa_chat_id,
|
||||
state,
|
||||
last_intent = null,
|
||||
last_order_id = null,
|
||||
context = {},
|
||||
}) {
|
||||
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, $3, now(), $4, $5, $6::jsonb, now())
|
||||
on conflict (tenant_id, wa_chat_id)
|
||||
do update set
|
||||
state = excluded.state,
|
||||
state_updated_at = now(),
|
||||
last_intent = excluded.last_intent,
|
||||
last_order_id = excluded.last_order_id,
|
||||
context = excluded.context,
|
||||
updated_at = now()
|
||||
returning tenant_id, wa_chat_id, state, last_intent, last_order_id, context, state_updated_at, updated_at
|
||||
`;
|
||||
const { rows } = await pool.query(q, [
|
||||
tenant_id,
|
||||
wa_chat_id,
|
||||
state,
|
||||
last_intent,
|
||||
last_order_id,
|
||||
JSON.stringify(context ?? {}),
|
||||
]);
|
||||
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,
|
||||
provider,
|
||||
message_id,
|
||||
direction, // in|out
|
||||
text = null,
|
||||
payload = {},
|
||||
run_id = null,
|
||||
ts = null,
|
||||
}) {
|
||||
const q = `
|
||||
insert into wa_messages
|
||||
(tenant_id, wa_chat_id, provider, message_id, direction, ts, text, payload, run_id)
|
||||
values
|
||||
($1, $2, $3, $4, $5, coalesce($6::timestamptz, now()), $7, $8::jsonb, $9)
|
||||
on conflict (tenant_id, provider, message_id) do nothing
|
||||
returning id
|
||||
`;
|
||||
const { rows } = await pool.query(q, [
|
||||
tenant_id,
|
||||
wa_chat_id,
|
||||
provider,
|
||||
message_id,
|
||||
direction,
|
||||
ts,
|
||||
text,
|
||||
JSON.stringify(payload ?? {}),
|
||||
run_id,
|
||||
]);
|
||||
return rows[0]?.id || null;
|
||||
}
|
||||
|
||||
export async function insertRun({
|
||||
tenant_id,
|
||||
wa_chat_id,
|
||||
message_id,
|
||||
prev_state = null,
|
||||
user_text = null,
|
||||
llm_output = null,
|
||||
tools = [],
|
||||
invariants = {},
|
||||
final_reply = null,
|
||||
order_id = null,
|
||||
payment_link = null,
|
||||
status = "ok",
|
||||
error_code = null,
|
||||
error_detail = null,
|
||||
latency_ms = null,
|
||||
}) {
|
||||
const q = `
|
||||
insert into conversation_runs
|
||||
(tenant_id, wa_chat_id, message_id, ts, prev_state, user_text, llm_output, tools, invariants,
|
||||
final_reply, order_id, payment_link, status, error_code, error_detail, latency_ms)
|
||||
values
|
||||
($1, $2, $3, now(), $4, $5, $6::jsonb, $7::jsonb, $8::jsonb,
|
||||
$9, $10, $11, $12, $13, $14, $15)
|
||||
on conflict (tenant_id, message_id) do nothing
|
||||
returning id
|
||||
`;
|
||||
const { rows } = await pool.query(q, [
|
||||
tenant_id,
|
||||
wa_chat_id,
|
||||
message_id,
|
||||
prev_state,
|
||||
user_text,
|
||||
llm_output ? JSON.stringify(llm_output) : null,
|
||||
JSON.stringify(tools ?? []),
|
||||
JSON.stringify(invariants ?? {}),
|
||||
final_reply,
|
||||
order_id,
|
||||
payment_link,
|
||||
status,
|
||||
error_code,
|
||||
error_detail,
|
||||
latency_ms,
|
||||
]);
|
||||
return rows[0]?.id || null;
|
||||
}
|
||||
|
||||
export async function updateRunLatency({ tenant_id, run_id, latency_ms }) {
|
||||
if (!tenant_id || !run_id) return false;
|
||||
const q = `
|
||||
update conversation_runs
|
||||
set latency_ms = $3
|
||||
where tenant_id = $1 and id = $2
|
||||
`;
|
||||
await pool.query(q, [tenant_id, run_id, latency_ms]);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function listConversations({ tenant_id, q = "", status = "", state = "", limit = 50 }) {
|
||||
const params = [tenant_id];
|
||||
let where = `where tenant_id=$1`;
|
||||
|
||||
if (q) {
|
||||
params.push(`%${q}%`);
|
||||
where += ` and wa_chat_id ilike $${params.length}`;
|
||||
}
|
||||
if (status) {
|
||||
// status derivado no implementado en MVP
|
||||
}
|
||||
if (state) {
|
||||
params.push(state);
|
||||
where += ` and state = $${params.length}`;
|
||||
}
|
||||
|
||||
const qsql = `
|
||||
select tenant_id, wa_chat_id,
|
||||
state,
|
||||
coalesce(last_intent,'other') as intent,
|
||||
updated_at as last_activity
|
||||
from wa_conversation_state
|
||||
${where}
|
||||
order by updated_at desc
|
||||
limit ${Math.max(1, Math.min(200, limit))}
|
||||
`;
|
||||
|
||||
const { rows } = await pool.query(qsql, params);
|
||||
|
||||
return rows.map((r) => ({
|
||||
chat_id: r.wa_chat_id,
|
||||
from: r.wa_chat_id.replace(/^sim:/, ""),
|
||||
state: r.state,
|
||||
intent: r.intent,
|
||||
status: "ok",
|
||||
last_activity: r.last_activity,
|
||||
last_run_id: null,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function listRuns({ tenant_id, wa_chat_id = null, limit = 50 }) {
|
||||
const params = [tenant_id];
|
||||
let where = `where tenant_id=$1`;
|
||||
if (wa_chat_id) {
|
||||
params.push(wa_chat_id);
|
||||
where += ` and wa_chat_id=$${params.length}`;
|
||||
}
|
||||
const q = `
|
||||
select id as run_id, ts, wa_chat_id as chat_id,
|
||||
status, prev_state, user_text,
|
||||
llm_output, tools, invariants,
|
||||
final_reply, order_id, payment_link, latency_ms
|
||||
from conversation_runs
|
||||
${where}
|
||||
order by ts desc
|
||||
limit ${Math.max(1, Math.min(200, limit))}
|
||||
`;
|
||||
const { rows } = await pool.query(q, params);
|
||||
|
||||
return rows.map((r) => ({
|
||||
run_id: r.run_id,
|
||||
ts: r.ts,
|
||||
chat_id: r.chat_id,
|
||||
from: r.chat_id.replace(/^sim:/, ""),
|
||||
status: r.status,
|
||||
prev_state: r.prev_state,
|
||||
input: { text: r.user_text },
|
||||
llm_output: r.llm_output,
|
||||
tools: r.tools,
|
||||
invariants: r.invariants,
|
||||
final_reply: r.final_reply,
|
||||
order_id: r.order_id,
|
||||
payment_link: r.payment_link,
|
||||
latency_ms: r.latency_ms,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getRunById({ tenant_id, run_id }) {
|
||||
const q = `
|
||||
select id as run_id, ts, wa_chat_id as chat_id,
|
||||
status, prev_state, user_text,
|
||||
llm_output, tools, invariants,
|
||||
final_reply, order_id, payment_link, latency_ms
|
||||
from conversation_runs
|
||||
where tenant_id=$1 and id=$2
|
||||
`;
|
||||
const { rows } = await pool.query(q, [tenant_id, run_id]);
|
||||
const r = rows[0];
|
||||
if (!r) return null;
|
||||
return {
|
||||
run_id: r.run_id,
|
||||
ts: r.ts,
|
||||
chat_id: r.chat_id,
|
||||
from: r.chat_id.replace(/^sim:/, ""),
|
||||
status: r.status,
|
||||
prev_state: r.prev_state,
|
||||
input: { text: r.user_text },
|
||||
llm_output: r.llm_output,
|
||||
tools: r.tools,
|
||||
invariants: r.invariants,
|
||||
final_reply: r.final_reply,
|
||||
order_id: r.order_id,
|
||||
payment_link: r.payment_link,
|
||||
latency_ms: r.latency_ms,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getRecentMessagesForLLM({
|
||||
tenant_id,
|
||||
wa_chat_id,
|
||||
limit = 20,
|
||||
maxCharsPerMessage = 800,
|
||||
}) {
|
||||
const lim = Math.max(1, Math.min(50, parseInt(limit, 10) || 20));
|
||||
const q = `
|
||||
select direction, ts, text
|
||||
from wa_messages
|
||||
where tenant_id=$1
|
||||
and wa_chat_id=$2
|
||||
and text is not null
|
||||
and length(trim(text)) > 0
|
||||
order by ts desc
|
||||
limit $3
|
||||
`;
|
||||
const { rows } = await pool.query(q, [tenant_id, wa_chat_id, lim]);
|
||||
|
||||
return rows.reverse().map((r) => ({
|
||||
role: r.direction === "in" ? "user" : "assistant",
|
||||
content: String(r.text).trim().slice(0, maxCharsPerMessage),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function listMessages({ tenant_id, wa_chat_id, limit = 200 }) {
|
||||
const lim = Math.max(1, Math.min(500, parseInt(limit, 10) || 200));
|
||||
const q = `
|
||||
select provider, message_id, direction, ts, text, payload, run_id
|
||||
from wa_messages
|
||||
where tenant_id=$1 and wa_chat_id=$2
|
||||
order by ts asc
|
||||
limit $3
|
||||
`;
|
||||
const { rows } = await pool.query(q, [tenant_id, wa_chat_id, lim]);
|
||||
return rows.map((r) => ({
|
||||
provider: r.provider,
|
||||
message_id: r.message_id,
|
||||
direction: r.direction,
|
||||
ts: r.ts,
|
||||
text: r.text,
|
||||
payload: r.payload,
|
||||
run_id: r.run_id,
|
||||
}));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export async function getTenantIdByChannel({ channel_type, channel_key }) {
|
||||
const q = `
|
||||
select tenant_id
|
||||
from tenant_channels
|
||||
where channel_type=$1 and channel_key=$2
|
||||
`;
|
||||
const { rows } = await pool.query(q, [channel_type, channel_key]);
|
||||
return rows[0]?.tenant_id || null;
|
||||
}
|
||||
|
||||
export async function getExternalCustomerIdByChat({ tenant_id, wa_chat_id, provider = "woo" }) {
|
||||
const q = `
|
||||
select external_customer_id
|
||||
from wa_identity_map
|
||||
where tenant_id=$1 and wa_chat_id=$2 and provider=$3
|
||||
`;
|
||||
const { rows } = await pool.query(q, [tenant_id, wa_chat_id, provider]);
|
||||
return rows[0]?.external_customer_id || null;
|
||||
}
|
||||
|
||||
export async function upsertExternalCustomerMap({
|
||||
tenant_id,
|
||||
wa_chat_id,
|
||||
external_customer_id,
|
||||
provider = "woo",
|
||||
}) {
|
||||
const q = `
|
||||
insert into wa_identity_map (tenant_id, wa_chat_id, provider, external_customer_id, created_at, updated_at)
|
||||
values ($1, $2, $3, $4, now(), now())
|
||||
on conflict (tenant_id, wa_chat_id)
|
||||
do update set external_customer_id = excluded.external_customer_id, updated_at = now()
|
||||
returning external_customer_id
|
||||
`;
|
||||
const { rows } = await pool.query(q, [tenant_id, wa_chat_id, provider, external_customer_id]);
|
||||
return rows[0]?.external_customer_id || null;
|
||||
}
|
||||
|
||||
export async function getTenantEcommerceConfig({ tenant_id, provider = "woo" }) {
|
||||
const q = `
|
||||
select id, tenant_id, provider, base_url, credential_ref, api_version, timeout_ms,
|
||||
enc_consumer_key, enc_consumer_secret, encryption_salt, enabled
|
||||
from tenant_ecommerce_config
|
||||
where tenant_id = $1 and provider = $2 and enabled = true
|
||||
limit 1
|
||||
`;
|
||||
const { rows } = await pool.query(q, [tenant_id, provider]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function getDecryptedTenantEcommerceConfig({
|
||||
tenant_id,
|
||||
provider = "woo",
|
||||
encryption_key,
|
||||
}) {
|
||||
if (!encryption_key) {
|
||||
throw new Error("encryption_key is required to decrypt ecommerce credentials");
|
||||
}
|
||||
const q = `
|
||||
select id, tenant_id, provider, base_url, credential_ref, api_version, timeout_ms, enabled,
|
||||
pgp_sym_decrypt(enc_consumer_key, $3)::text as consumer_key,
|
||||
pgp_sym_decrypt(enc_consumer_secret, $3)::text as consumer_secret
|
||||
from tenant_ecommerce_config
|
||||
where tenant_id = $1 and provider = $2 and enabled = true
|
||||
limit 1
|
||||
`;
|
||||
const { rows } = await pool.query(q, [tenant_id, provider, encryption_key]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function searchProductAliases({ 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 [];
|
||||
const normalized = query.toLowerCase();
|
||||
const like = `%${query}%`;
|
||||
const nlike = `%${normalized}%`;
|
||||
const sql = `
|
||||
select tenant_id, alias, normalized_alias, woo_product_id, category_hint, boost, metadata, updated_at
|
||||
from product_aliases
|
||||
where tenant_id=$1
|
||||
and (alias ilike $2 or normalized_alias ilike $3)
|
||||
order by boost desc, updated_at desc
|
||||
limit $4
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenant_id, like, nlike, lim]);
|
||||
return rows.map((r) => ({
|
||||
tenant_id: r.tenant_id,
|
||||
alias: r.alias,
|
||||
normalized_alias: r.normalized_alias,
|
||||
woo_product_id: r.woo_product_id,
|
||||
category_hint: r.category_hint,
|
||||
boost: r.boost,
|
||||
metadata: r.metadata,
|
||||
updated_at: r.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getProductEmbedding({ tenant_id, content_hash }) {
|
||||
const sql = `
|
||||
select tenant_id, content_hash, content_text, embedding, model, updated_at
|
||||
from product_embeddings_cache
|
||||
where tenant_id=$1 and content_hash=$2
|
||||
limit 1
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenant_id, content_hash]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function upsertProductEmbedding({
|
||||
tenant_id,
|
||||
content_hash,
|
||||
content_text,
|
||||
embedding,
|
||||
model,
|
||||
}) {
|
||||
const sql = `
|
||||
insert into product_embeddings_cache
|
||||
(tenant_id, content_hash, content_text, embedding, model, updated_at)
|
||||
values
|
||||
($1, $2, $3, $4::jsonb, $5, now())
|
||||
on conflict (tenant_id, content_hash)
|
||||
do update set
|
||||
content_text = excluded.content_text,
|
||||
embedding = excluded.embedding,
|
||||
model = excluded.model,
|
||||
updated_at = now()
|
||||
returning tenant_id, content_hash, content_text, embedding, model, updated_at
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [
|
||||
tenant_id,
|
||||
content_hash,
|
||||
content_text,
|
||||
JSON.stringify(embedding ?? []),
|
||||
model,
|
||||
]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function upsertMpPayment({
|
||||
tenant_id,
|
||||
woo_order_id = null,
|
||||
preference_id = null,
|
||||
payment_id = null,
|
||||
status = null,
|
||||
paid_at = null,
|
||||
raw = {},
|
||||
}) {
|
||||
if (!payment_id) throw new Error("payment_id_required");
|
||||
const sql = `
|
||||
insert into mp_payments
|
||||
(tenant_id, woo_order_id, preference_id, payment_id, status, paid_at, raw, created_at, updated_at)
|
||||
values
|
||||
($1, $2, $3, $4, $5, $6::timestamptz, $7::jsonb, now(), now())
|
||||
on conflict (tenant_id, payment_id)
|
||||
do update set
|
||||
woo_order_id = excluded.woo_order_id,
|
||||
preference_id = excluded.preference_id,
|
||||
status = excluded.status,
|
||||
paid_at = excluded.paid_at,
|
||||
raw = excluded.raw,
|
||||
updated_at = now()
|
||||
returning tenant_id, woo_order_id, preference_id, payment_id, status, paid_at, raw, updated_at
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [
|
||||
tenant_id,
|
||||
woo_order_id,
|
||||
preference_id,
|
||||
payment_id,
|
||||
status,
|
||||
paid_at,
|
||||
JSON.stringify(raw ?? {}),
|
||||
]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function getMpPaymentById({ tenant_id, payment_id }) {
|
||||
const sql = `
|
||||
select tenant_id, woo_order_id, preference_id, payment_id, status, paid_at, raw, updated_at
|
||||
from mp_payments
|
||||
where tenant_id=$1 and payment_id=$2
|
||||
limit 1
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenant_id, payment_id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
9
src/modules/2-identity/routes/wooWebhooks.js
Normal file
9
src/modules/2-identity/routes/wooWebhooks.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import express from "express";
|
||||
import { makeWooProductWebhook } from "../controllers/wooWebhooks.js";
|
||||
|
||||
export function createWooWebhooksRouter() {
|
||||
const router = express.Router();
|
||||
router.post("/webhook/woo/products", makeWooProductWebhook());
|
||||
return router;
|
||||
}
|
||||
|
||||
437
src/modules/2-identity/services/pipeline.js
Normal file
437
src/modules/2-identity/services/pipeline.js
Normal file
@@ -0,0 +1,437 @@
|
||||
import crypto from "crypto";
|
||||
import {
|
||||
getConversationState,
|
||||
insertMessage,
|
||||
insertRun,
|
||||
touchConversationState,
|
||||
upsertConversationState,
|
||||
getRecentMessagesForLLM,
|
||||
getExternalCustomerIdByChat,
|
||||
upsertExternalCustomerMap,
|
||||
updateRunLatency,
|
||||
getTenantByKey,
|
||||
getTenantIdByChannel,
|
||||
} from "../db/repo.js";
|
||||
import { sseSend } from "../../shared/sse.js";
|
||||
import { createWooCustomer, getWooCustomerById } from "./woo.js";
|
||||
import { debug as dbg } from "../../shared/debug.js";
|
||||
import { runTurnV3 } from "../../3-turn-engine/turnEngineV3.js";
|
||||
import { safeNextState } from "../../3-turn-engine/fsm.js";
|
||||
import { createOrder, updateOrder } from "../../4-woo-orders/wooOrders.js";
|
||||
import { createPreference } from "../../6-mercadopago/mercadoPago.js";
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function newId(prefix = "run") {
|
||||
return `${prefix}_${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
function makePerf() {
|
||||
const started_at = Date.now();
|
||||
const perf = { t0: started_at, marks: {} };
|
||||
const mark = (name) => {
|
||||
perf.marks[name] = Date.now();
|
||||
};
|
||||
const msBetween = (a, b) => {
|
||||
const ta = a === "t0" ? perf.t0 : perf.marks[a];
|
||||
const tb = b === "t0" ? perf.t0 : perf.marks[b];
|
||||
if (!ta || !tb) return null;
|
||||
return tb - ta;
|
||||
};
|
||||
return { started_at, perf, mark, msBetween };
|
||||
}
|
||||
|
||||
function logStage(enabled, stage, payload) {
|
||||
if (!enabled) return;
|
||||
console.log(`[pipeline] ${stage}`, payload);
|
||||
}
|
||||
|
||||
function collapseAssistantMessages(messages) {
|
||||
const out = [];
|
||||
for (const m of messages || []) {
|
||||
const last = out[out.length - 1];
|
||||
if (last && last.role === "assistant" && m.role === "assistant") continue;
|
||||
out.push(m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function ensureWooCustomerId({
|
||||
tenantId,
|
||||
chat_id,
|
||||
displayName,
|
||||
from,
|
||||
externalCustomerId,
|
||||
run_id,
|
||||
}) {
|
||||
let updatedId = externalCustomerId;
|
||||
let error = null;
|
||||
try {
|
||||
if (updatedId) {
|
||||
const found = await getWooCustomerById({ tenantId, id: updatedId });
|
||||
if (!found) {
|
||||
const phone = chat_id.replace(/@.+$/, "");
|
||||
const name = displayName || from || phone;
|
||||
const created = await createWooCustomer({ tenantId, wa_chat_id: chat_id, phone, name });
|
||||
if (!created?.id) throw new Error("woo_customer_id_missing");
|
||||
updatedId = await upsertExternalCustomerMap({
|
||||
tenant_id: tenantId,
|
||||
wa_chat_id: chat_id,
|
||||
external_customer_id: created?.id,
|
||||
provider: "woo",
|
||||
});
|
||||
} else {
|
||||
updatedId = await upsertExternalCustomerMap({
|
||||
tenant_id: tenantId,
|
||||
wa_chat_id: chat_id,
|
||||
external_customer_id: updatedId,
|
||||
provider: "woo",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const phone = chat_id.replace(/@.+$/, "");
|
||||
const name = displayName || from || phone;
|
||||
const created = await createWooCustomer({ tenantId, wa_chat_id: chat_id, phone, name });
|
||||
if (!created?.id) throw new Error("woo_customer_id_missing");
|
||||
updatedId = await upsertExternalCustomerMap({
|
||||
tenant_id: tenantId,
|
||||
wa_chat_id: chat_id,
|
||||
external_customer_id: created?.id,
|
||||
provider: "woo",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
error = {
|
||||
message: String(e?.message || e),
|
||||
status: e?.status || e?.cause?.status || null,
|
||||
code: e?.body?.code || e?.cause?.body?.code || null,
|
||||
run_id: run_id || null,
|
||||
};
|
||||
}
|
||||
return { external_customer_id: updatedId, error };
|
||||
}
|
||||
|
||||
export async function processMessage({
|
||||
tenantId,
|
||||
chat_id,
|
||||
from,
|
||||
text,
|
||||
provider,
|
||||
message_id,
|
||||
displayName = null,
|
||||
meta = null,
|
||||
}) {
|
||||
const { started_at, mark, msBetween } = makePerf();
|
||||
|
||||
await touchConversationState({ tenant_id: tenantId, wa_chat_id: chat_id });
|
||||
|
||||
mark("start");
|
||||
const stageDebug = dbg.perf;
|
||||
const prev = await getConversationState(tenantId, chat_id);
|
||||
mark("after_getConversationState");
|
||||
const isStale =
|
||||
prev?.state_updated_at &&
|
||||
Date.now() - new Date(prev.state_updated_at).getTime() > 24 * 60 * 60 * 1000;
|
||||
const prev_state = isStale ? "IDLE" : prev?.state || "IDLE";
|
||||
let externalCustomerId = await getExternalCustomerIdByChat({
|
||||
tenant_id: tenantId,
|
||||
wa_chat_id: chat_id,
|
||||
provider: "woo",
|
||||
});
|
||||
mark("after_getExternalCustomerIdByChat");
|
||||
|
||||
await insertMessage({
|
||||
tenant_id: tenantId,
|
||||
wa_chat_id: chat_id,
|
||||
provider,
|
||||
message_id,
|
||||
direction: "in",
|
||||
text,
|
||||
payload: { raw: { from, text, meta } },
|
||||
run_id: null,
|
||||
});
|
||||
mark("after_insertMessage_in");
|
||||
|
||||
mark("before_getRecentMessagesForLLM_for_plan");
|
||||
const history = await getRecentMessagesForLLM({
|
||||
tenant_id: tenantId,
|
||||
wa_chat_id: chat_id,
|
||||
limit: 20,
|
||||
});
|
||||
const conversation_history = collapseAssistantMessages(history);
|
||||
mark("after_getRecentMessagesForLLM_for_plan");
|
||||
|
||||
logStage(stageDebug, "history", { has_history: Array.isArray(conversation_history), state: prev_state });
|
||||
|
||||
let reducedContext = prev?.context && typeof prev?.context === "object" ? { ...prev.context } : {};
|
||||
let decision;
|
||||
let plan;
|
||||
let llmMeta;
|
||||
let tools = [];
|
||||
|
||||
mark("before_turn_v3");
|
||||
const out = await runTurnV3({
|
||||
tenantId,
|
||||
chat_id,
|
||||
text,
|
||||
prev_state,
|
||||
prev_context: reducedContext,
|
||||
conversation_history,
|
||||
});
|
||||
plan = out.plan;
|
||||
decision = out.decision || { context_patch: {}, actions: [], audit: {} };
|
||||
llmMeta = { kind: "nlu_v3", audit: decision.audit || null };
|
||||
tools = [];
|
||||
mark("after_turn_v3");
|
||||
|
||||
const runStatus = llmMeta?.error ? "warn" : "ok";
|
||||
const isSimulated = provider === "sim" || meta?.source === "sim";
|
||||
|
||||
const invariants = {
|
||||
ok: true,
|
||||
checks: [
|
||||
{ name: "required_keys_present", ok: true },
|
||||
{ name: "no_checkout_without_payment_link", ok: true },
|
||||
{ name: "no_order_action_without_items", ok: true },
|
||||
],
|
||||
};
|
||||
mark("before_insertRun");
|
||||
|
||||
// --- Ejecutar acciones determinísticas ---
|
||||
let actionPatch = {};
|
||||
if (Array.isArray(decision?.actions) && decision.actions.length) {
|
||||
const newTools = [];
|
||||
const actions = decision.actions;
|
||||
|
||||
const calcOrderTotal = (order) => {
|
||||
const rawTotal = Number(order?.raw?.total);
|
||||
if (Number.isFinite(rawTotal) && rawTotal > 0) return rawTotal;
|
||||
const items = Array.isArray(order?.line_items) ? order.line_items : [];
|
||||
let sum = 0;
|
||||
for (const it of items) {
|
||||
const t = Number(it?.total);
|
||||
if (Number.isFinite(t)) sum += t;
|
||||
}
|
||||
return sum > 0 ? sum : null;
|
||||
};
|
||||
|
||||
const needsWoo = actions.some((a) => a.type === "create_order" || a.type === "update_order");
|
||||
if (needsWoo) {
|
||||
const ensured = await ensureWooCustomerId({
|
||||
tenantId,
|
||||
chat_id,
|
||||
displayName,
|
||||
from,
|
||||
externalCustomerId,
|
||||
});
|
||||
externalCustomerId = ensured.external_customer_id;
|
||||
if (ensured.error) {
|
||||
newTools.push({ type: "ensure_woo_customer", ok: false, error: ensured.error });
|
||||
} else {
|
||||
newTools.push({ type: "ensure_woo_customer", ok: true, external_customer_id: externalCustomerId });
|
||||
}
|
||||
}
|
||||
|
||||
for (const act of actions) {
|
||||
try {
|
||||
if (act.type === "create_order") {
|
||||
const order = await createOrder({
|
||||
tenantId,
|
||||
wooCustomerId: externalCustomerId,
|
||||
basket: reducedContext?.order_basket || plan?.basket_resolved || { items: [] },
|
||||
address: reducedContext?.delivery_address || reducedContext?.address || null,
|
||||
run_id: null,
|
||||
});
|
||||
actionPatch.woo_order_id = order?.id || null;
|
||||
actionPatch.order_total = calcOrderTotal(order);
|
||||
newTools.push({ type: "create_order", ok: true, order_id: order?.id || null });
|
||||
} else if (act.type === "update_order") {
|
||||
const order = await updateOrder({
|
||||
tenantId,
|
||||
wooOrderId: reducedContext?.woo_order_id || prev?.last_order_id || null,
|
||||
basket: reducedContext?.order_basket || plan?.basket_resolved || { items: [] },
|
||||
address: reducedContext?.delivery_address || reducedContext?.address || null,
|
||||
run_id: null,
|
||||
});
|
||||
actionPatch.woo_order_id = order?.id || null;
|
||||
actionPatch.order_total = calcOrderTotal(order);
|
||||
newTools.push({ type: "update_order", ok: true, order_id: order?.id || null });
|
||||
} else if (act.type === "send_payment_link") {
|
||||
const total = Number(actionPatch?.order_total || reducedContext?.order_total || 0) || null;
|
||||
if (!total || total <= 0) {
|
||||
throw new Error("order_total_missing");
|
||||
}
|
||||
const pref = await createPreference({
|
||||
tenantId,
|
||||
wooOrderId: actionPatch.woo_order_id || reducedContext?.woo_order_id || prev?.last_order_id,
|
||||
amount: total || 0,
|
||||
});
|
||||
actionPatch.payment_link = pref?.init_point || null;
|
||||
actionPatch.mp = {
|
||||
preference_id: pref?.preference_id || null,
|
||||
init_point: pref?.init_point || null,
|
||||
};
|
||||
newTools.push({ type: "send_payment_link", ok: true, preference_id: pref?.preference_id || null });
|
||||
if (pref?.init_point) {
|
||||
plan.reply = `Listo, acá tenés el link de pago: ${pref.init_point}\nAvisame cuando esté listo.`;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
newTools.push({ type: act.type, ok: false, error: String(e?.message || e) });
|
||||
}
|
||||
}
|
||||
|
||||
tools = newTools;
|
||||
}
|
||||
|
||||
const run_id = await insertRun({
|
||||
tenant_id: tenantId,
|
||||
wa_chat_id: chat_id,
|
||||
message_id: `${provider}:${message_id}`,
|
||||
prev_state,
|
||||
user_text: text,
|
||||
llm_output: { ...plan, _llm: llmMeta },
|
||||
tools,
|
||||
invariants,
|
||||
final_reply: plan.reply,
|
||||
status: runStatus,
|
||||
latency_ms: null,
|
||||
});
|
||||
mark("after_insertRun");
|
||||
|
||||
const outMessageId = newId("out");
|
||||
await insertMessage({
|
||||
tenant_id: tenantId,
|
||||
wa_chat_id: chat_id,
|
||||
provider,
|
||||
message_id: outMessageId,
|
||||
direction: "out",
|
||||
text: plan.reply,
|
||||
payload: { reply: plan.reply, railguard: { simulated: isSimulated, source: meta?.source || null } },
|
||||
run_id,
|
||||
});
|
||||
mark("after_insertMessage_out");
|
||||
|
||||
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 }, railguard: { simulated: isSimulated, source: meta?.source || null } },
|
||||
run_id,
|
||||
});
|
||||
}
|
||||
|
||||
let wooCustomerError = null;
|
||||
if (tools.some((t) => t.type === "ensure_woo_customer" && !t.ok)) {
|
||||
wooCustomerError = tools.find((t) => t.type === "ensure_woo_customer" && !t.ok)?.error || null;
|
||||
}
|
||||
|
||||
const context = {
|
||||
...(reducedContext || {}),
|
||||
...(decision?.context_patch || {}),
|
||||
...(actionPatch || {}),
|
||||
missing_fields: plan.missing_fields || [],
|
||||
basket_resolved: (reducedContext?.order_basket?.items?.length ? reducedContext.order_basket : plan.basket_resolved) || { items: [] },
|
||||
external_customer_id: externalCustomerId ?? prev?.context?.external_customer_id ?? null,
|
||||
railguard: { simulated: isSimulated, source: meta?.source || null },
|
||||
woo_customer_error: wooCustomerError,
|
||||
};
|
||||
|
||||
const nextState = safeNextState(prev_state, context, { requested_checkout: plan.intent === "checkout" }).next_state;
|
||||
plan.next_state = nextState;
|
||||
|
||||
const stateRow = await upsertConversationState({
|
||||
tenant_id: tenantId,
|
||||
wa_chat_id: chat_id,
|
||||
state: nextState,
|
||||
last_intent: plan.intent,
|
||||
last_order_id: null,
|
||||
context,
|
||||
});
|
||||
mark("after_upsertConversationState");
|
||||
|
||||
sseSend("conversation.upsert", {
|
||||
chat_id: stateRow.wa_chat_id,
|
||||
from: stateRow.wa_chat_id.replace(/^sim:/, ""),
|
||||
state: stateRow.state,
|
||||
intent: stateRow.last_intent || "other",
|
||||
status: runStatus,
|
||||
last_activity: stateRow.updated_at,
|
||||
last_run_id: run_id,
|
||||
});
|
||||
|
||||
const end_to_end_ms = Date.now() - started_at;
|
||||
if (run_id) {
|
||||
await updateRunLatency({ tenant_id: tenantId, run_id, latency_ms: end_to_end_ms });
|
||||
}
|
||||
|
||||
sseSend("run.created", {
|
||||
run_id,
|
||||
ts: nowIso(),
|
||||
chat_id,
|
||||
from,
|
||||
status: runStatus,
|
||||
prev_state,
|
||||
input: { text },
|
||||
llm_output: { ...plan, _llm: llmMeta },
|
||||
tools,
|
||||
invariants,
|
||||
final_reply: plan.reply,
|
||||
order_id: actionPatch.woo_order_id || null,
|
||||
payment_link: actionPatch.payment_link || null,
|
||||
latency_ms: end_to_end_ms,
|
||||
});
|
||||
|
||||
console.log("[perf] processMessage", {
|
||||
tenantId,
|
||||
chat_id,
|
||||
provider,
|
||||
message_id,
|
||||
run_id,
|
||||
end_to_end_ms,
|
||||
ms: {
|
||||
db_state_ms: msBetween("start", "after_getConversationState"),
|
||||
db_identity_ms: msBetween("after_getConversationState", "after_getExternalCustomerIdByChat"),
|
||||
insert_in_ms: msBetween("after_getExternalCustomerIdByChat", "after_insertMessage_in"),
|
||||
history_for_plan_ms: msBetween("after_insertMessage_in", "after_getRecentMessagesForLLM_for_plan"),
|
||||
insert_run_ms: msBetween("before_insertRun", "after_insertRun"),
|
||||
insert_out_ms: msBetween("after_insertRun", "after_insertMessage_out"),
|
||||
upsert_state_ms: msBetween("after_insertMessage_out", "after_upsertConversationState"),
|
||||
},
|
||||
});
|
||||
|
||||
return { run_id, reply: plan.reply };
|
||||
}
|
||||
|
||||
function parseTenantFromChatId(chat_id) {
|
||||
const m = /^([a-z0-9_-]+):/.exec(chat_id);
|
||||
return m?.[1]?.toLowerCase() || null;
|
||||
}
|
||||
|
||||
export async function resolveTenantId({ chat_id, to_phone = null, tenant_key = null }) {
|
||||
const explicit = (tenant_key || parseTenantFromChatId(chat_id) || "").toLowerCase();
|
||||
|
||||
if (explicit) {
|
||||
const t = await getTenantByKey(explicit);
|
||||
if (t) return t.id;
|
||||
throw new Error(`tenant_not_found: ${explicit}`);
|
||||
}
|
||||
|
||||
if (to_phone) {
|
||||
const id = await getTenantIdByChannel({ channel_type: "whatsapp", channel_key: to_phone });
|
||||
if (id) return id;
|
||||
}
|
||||
|
||||
const fallbackKey = (process.env.TENANT_KEY || "piaf").toLowerCase();
|
||||
const t = await getTenantByKey(fallbackKey);
|
||||
if (t) return t.id;
|
||||
throw new Error(`tenant_not_found: ${fallbackKey}`);
|
||||
}
|
||||
|
||||
433
src/modules/2-identity/services/woo.js
Normal file
433
src/modules/2-identity/services/woo.js
Normal file
@@ -0,0 +1,433 @@
|
||||
import crypto from "crypto";
|
||||
import { getDecryptedTenantEcommerceConfig } from "../db/repo.js";
|
||||
import { debug } from "../../shared/debug.js";
|
||||
|
||||
// --- Simple in-memory lock to serialize work per key (e.g. wa_chat_id) ---
|
||||
const locks = new Map();
|
||||
|
||||
async function withLock(key, fn) {
|
||||
const prev = locks.get(key) || Promise.resolve();
|
||||
let release;
|
||||
const next = new Promise((r) => (release = r));
|
||||
locks.set(key, prev.then(() => next));
|
||||
|
||||
const queuedAt = Date.now();
|
||||
await prev;
|
||||
const acquiredAt = Date.now();
|
||||
try {
|
||||
return await fn({ lock_wait_ms: acquiredAt - queuedAt });
|
||||
} finally {
|
||||
release();
|
||||
// cleanup
|
||||
if (locks.get(key) === next) locks.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function getDeep(obj, path) {
|
||||
let cur = obj;
|
||||
for (const k of path) {
|
||||
cur = cur?.[k];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
function isRetryableNetworkError(err) {
|
||||
// fetchWoo wraps errors as { message: "...", cause: originalError }
|
||||
const e0 = err;
|
||||
const e1 = err?.cause;
|
||||
const e2 = getDeep(err, ["cause", "cause"]);
|
||||
|
||||
const candidates = [e0, e1, e2].filter(Boolean);
|
||||
const codes = new Set(candidates.map((e) => e.code).filter(Boolean));
|
||||
const names = new Set(candidates.map((e) => e.name).filter(Boolean));
|
||||
const messages = candidates.map((e) => String(e.message || "")).join(" | ").toLowerCase();
|
||||
|
||||
const aborted =
|
||||
names.has("AbortError") ||
|
||||
messages.includes("aborted") ||
|
||||
messages.includes("timeout") ||
|
||||
messages.includes("timed out");
|
||||
|
||||
// ECONNRESET/ETIMEDOUT are common; undici may also surface UND_ERR_* codes
|
||||
const retryCodes = new Set(["ECONNRESET", "ETIMEDOUT", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_SOCKET"]);
|
||||
const byCode = [...codes].some((c) => retryCodes.has(c));
|
||||
|
||||
return aborted || byCode;
|
||||
}
|
||||
|
||||
async function fetchWoo({ url, method = "GET", body = null, timeout = 20000, 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,
|
||||
});
|
||||
if (debug.wooHttp) console.log("woo headers in", Date.now() - t0, "ms", res.status);
|
||||
const text = await res.text();
|
||||
if (debug.wooHttp) console.log("woo body in", Date.now() - t0, "ms", "len", text.length);
|
||||
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 = redactWooUrl(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;
|
||||
// Propagar status/body para que el caller pueda decidir retries/auth fallback
|
||||
err.status = e?.status || null;
|
||||
err.body = e?.body || null;
|
||||
err.url = redactWooUrl(url);
|
||||
err.method = method;
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function redactWooUrl(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.searchParams.has("consumer_key")) u.searchParams.set("consumer_key", "REDACTED");
|
||||
if (u.searchParams.has("consumer_secret")) u.searchParams.set("consumer_secret", "REDACTED");
|
||||
return u.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchWooCustomerByEmail({ base, consumerKey, consumerSecret, email, timeout }) {
|
||||
const url = `${base}/customers?email=${encodeURIComponent(email)}`;
|
||||
const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString("base64");
|
||||
const data = await fetchWoo({
|
||||
url,
|
||||
method: "GET",
|
||||
timeout,
|
||||
headers: { Authorization: `Basic ${auth}` },
|
||||
});
|
||||
if (Array.isArray(data) && data.length > 0) return data[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
async function searchWooCustomerByUsername({ base, consumerKey, consumerSecret, username, timeout }) {
|
||||
const url = `${base}/customers?search=${encodeURIComponent(username)}`;
|
||||
const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString("base64");
|
||||
const data = await fetchWoo({
|
||||
url,
|
||||
method: "GET",
|
||||
timeout,
|
||||
headers: { Authorization: `Basic ${auth}` },
|
||||
});
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
const exact = data.find((c) => c.username === username);
|
||||
return exact || data[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function createWooCustomer({ tenantId, wa_chat_id, phone, name }) {
|
||||
const lockKey = `${tenantId}:${wa_chat_id}`;
|
||||
const t0 = Date.now();
|
||||
return withLock(lockKey, async ({ lock_wait_ms }) => {
|
||||
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 email = `${phone || wa_chat_id}@no-email.local`;
|
||||
const username = wa_chat_id;
|
||||
const timeout = Math.max(cfg.timeout_ms ?? 20000, 20000);
|
||||
const getTimeout = timeout;
|
||||
const postTimeout = 2000;
|
||||
|
||||
// Para existencia/idempotencia: email primero (más estable y liviano que search=...)
|
||||
const existing = await searchWooCustomerByEmail({
|
||||
base,
|
||||
consumerKey,
|
||||
consumerSecret,
|
||||
email,
|
||||
timeout: getTimeout,
|
||||
});
|
||||
if (existing?.id) {
|
||||
const total_ms = Date.now() - t0;
|
||||
console.log("[perf] woo.createCustomer", {
|
||||
tenantId,
|
||||
wa_chat_id,
|
||||
reused: true,
|
||||
reason: "email_hit",
|
||||
lock_wait_ms,
|
||||
total_ms,
|
||||
});
|
||||
return { id: existing.id, raw: existing, reused: true };
|
||||
}
|
||||
|
||||
// Fallback (solo por compatibilidad): si alguien creó el customer con username pero email distinto
|
||||
const existingByUser = await searchWooCustomerByUsername({
|
||||
base,
|
||||
consumerKey,
|
||||
consumerSecret,
|
||||
username,
|
||||
timeout: getTimeout,
|
||||
});
|
||||
if (existingByUser?.id) {
|
||||
const total_ms = Date.now() - t0;
|
||||
console.log("[perf] woo.createCustomer", {
|
||||
tenantId,
|
||||
wa_chat_id,
|
||||
reused: true,
|
||||
reason: "username_hit",
|
||||
lock_wait_ms,
|
||||
total_ms,
|
||||
});
|
||||
return { id: existingByUser.id, raw: existingByUser, reused: true };
|
||||
}
|
||||
|
||||
const url = `${base}/customers`;
|
||||
const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString("base64");
|
||||
|
||||
const payload = {
|
||||
email,
|
||||
first_name: name || phone || wa_chat_id,
|
||||
username,
|
||||
password: crypto.randomBytes(12).toString("base64url"), // requerido por Woo
|
||||
billing: {
|
||||
phone: phone || wa_chat_id,
|
||||
},
|
||||
};
|
||||
|
||||
const doPost = async () =>
|
||||
await fetchWoo({
|
||||
url,
|
||||
method: "POST",
|
||||
body: payload,
|
||||
timeout: postTimeout,
|
||||
headers: { Authorization: `Basic ${auth}` },
|
||||
});
|
||||
|
||||
try {
|
||||
const data = await doPost();
|
||||
const total_ms = Date.now() - t0;
|
||||
console.log("[perf] woo.createCustomer", {
|
||||
tenantId,
|
||||
wa_chat_id,
|
||||
reused: false,
|
||||
reason: "post_ok",
|
||||
lock_wait_ms,
|
||||
total_ms,
|
||||
});
|
||||
return { id: data?.id, raw: data, reused: false };
|
||||
} catch (err) {
|
||||
// Caso estándar: Woo dice "email exists" => recuperar por email y devolver
|
||||
if (err.status === 400 && err.body?.code === "registration-error-email-exists") {
|
||||
const found = await searchWooCustomerByEmail({
|
||||
base,
|
||||
consumerKey,
|
||||
consumerSecret,
|
||||
email,
|
||||
timeout: getTimeout,
|
||||
});
|
||||
if (found?.id) {
|
||||
const total_ms = Date.now() - t0;
|
||||
console.log("[perf] woo.createCustomer", {
|
||||
tenantId,
|
||||
wa_chat_id,
|
||||
reused: true,
|
||||
reason: "email_exists_recovered",
|
||||
lock_wait_ms,
|
||||
total_ms,
|
||||
});
|
||||
return { id: found.id, raw: found, reused: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Retry seguro solo para timeouts / ECONNRESET:
|
||||
// POST pudo haber creado el customer pero la respuesta no volvió => hacer GET por email.
|
||||
if (isRetryableNetworkError(err)) {
|
||||
await sleep(300 + Math.floor(Math.random() * 300));
|
||||
|
||||
const foundAfterTimeout = await searchWooCustomerByEmail({
|
||||
base,
|
||||
consumerKey,
|
||||
consumerSecret,
|
||||
email,
|
||||
timeout: getTimeout,
|
||||
});
|
||||
if (foundAfterTimeout?.id) {
|
||||
const total_ms = Date.now() - t0;
|
||||
console.log("[perf] woo.createCustomer", {
|
||||
tenantId,
|
||||
wa_chat_id,
|
||||
reused: true,
|
||||
reason: "timeout_get_recovered",
|
||||
lock_wait_ms,
|
||||
total_ms,
|
||||
});
|
||||
return { id: foundAfterTimeout.id, raw: foundAfterTimeout, reused: true };
|
||||
}
|
||||
|
||||
// si no existe, reintentar POST una vez
|
||||
try {
|
||||
const data2 = await doPost();
|
||||
const total_ms = Date.now() - t0;
|
||||
console.log("[perf] woo.createCustomer", {
|
||||
tenantId,
|
||||
wa_chat_id,
|
||||
reused: false,
|
||||
reason: "timeout_post_retry_ok",
|
||||
lock_wait_ms,
|
||||
total_ms,
|
||||
});
|
||||
return { id: data2?.id, raw: data2, reused: false };
|
||||
} catch (err2) {
|
||||
// último intento de "recovery" (por si 2do POST también timeouteó)
|
||||
if (isRetryableNetworkError(err2)) {
|
||||
const foundAfterTimeout2 = await searchWooCustomerByEmail({
|
||||
base,
|
||||
consumerKey,
|
||||
consumerSecret,
|
||||
email,
|
||||
timeout: getTimeout,
|
||||
});
|
||||
if (foundAfterTimeout2?.id) {
|
||||
const total_ms = Date.now() - t0;
|
||||
console.log("[perf] woo.createCustomer", {
|
||||
tenantId,
|
||||
wa_chat_id,
|
||||
reused: true,
|
||||
reason: "timeout_post_retry_get_recovered",
|
||||
lock_wait_ms,
|
||||
total_ms,
|
||||
});
|
||||
return { id: foundAfterTimeout2.id, raw: foundAfterTimeout2, reused: true };
|
||||
}
|
||||
}
|
||||
throw err2;
|
||||
}
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWooCustomerById({ tenantId, id }) {
|
||||
if (!id) return null;
|
||||
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 url = `${base}/customers/${encodeURIComponent(id)}`;
|
||||
const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString("base64");
|
||||
|
||||
try {
|
||||
const data = await fetchWoo({
|
||||
url,
|
||||
method: "GET",
|
||||
timeout: cfg.timeout_ms,
|
||||
headers: { Authorization: `Basic ${auth}` },
|
||||
});
|
||||
return data;
|
||||
} catch (err) {
|
||||
if (err.status === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
210
src/modules/3-turn-engine/catalogRetrieval.js
Normal file
210
src/modules/3-turn-engine/catalogRetrieval.js
Normal file
@@ -0,0 +1,210 @@
|
||||
import crypto from "crypto";
|
||||
import OpenAI from "openai";
|
||||
import { debug as dbg } from "../shared/debug.js";
|
||||
import { searchSnapshotItems } from "../shared/wooSnapshot.js";
|
||||
import {
|
||||
searchProductAliases,
|
||||
getProductEmbedding,
|
||||
upsertProductEmbedding,
|
||||
} from "../2-identity/db/repo.js";
|
||||
|
||||
function getOpenAiKey() {
|
||||
return process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY || null;
|
||||
}
|
||||
|
||||
function getEmbeddingsModel() {
|
||||
return process.env.OPENAI_EMBEDDINGS_MODEL || "text-embedding-3-small";
|
||||
}
|
||||
|
||||
function normalizeText(s) {
|
||||
return String(s || "")
|
||||
.toLowerCase()
|
||||
.replace(/[¿?¡!.,;:()"]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function hashText(s) {
|
||||
return crypto.createHash("sha256").update(String(s || "")).digest("hex");
|
||||
}
|
||||
|
||||
function cosine(a, b) {
|
||||
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length || a.length === 0) return 0;
|
||||
let dot = 0;
|
||||
let na = 0;
|
||||
let nb = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const x = Number(a[i]) || 0;
|
||||
const y = Number(b[i]) || 0;
|
||||
dot += x * y;
|
||||
na += x * x;
|
||||
nb += y * y;
|
||||
}
|
||||
if (na === 0 || nb === 0) return 0;
|
||||
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
||||
}
|
||||
|
||||
function candidateText(c) {
|
||||
const parts = [c?.name || ""];
|
||||
if (Array.isArray(c?.categories)) {
|
||||
for (const cat of c.categories) {
|
||||
if (cat?.name) parts.push(cat.name);
|
||||
if (cat?.slug) parts.push(cat.slug);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(c?.attributes)) {
|
||||
for (const a of c.attributes) {
|
||||
if (a?.name) parts.push(a.name);
|
||||
if (Array.isArray(a?.options)) parts.push(a.options.join(" "));
|
||||
}
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function literalScore(query, candidate) {
|
||||
const q = normalizeText(query);
|
||||
const n = normalizeText(candidate?.name || "");
|
||||
if (!q || !n) return 0;
|
||||
if (n === q) return 1.0;
|
||||
if (n.includes(q)) return 0.7;
|
||||
const qt = new Set(q.split(" ").filter(Boolean));
|
||||
const nt = new Set(n.split(" ").filter(Boolean));
|
||||
let hits = 0;
|
||||
for (const w of qt) if (nt.has(w)) hits++;
|
||||
return hits / Math.max(qt.size, 1);
|
||||
}
|
||||
|
||||
async function embedText({ tenantId, text }) {
|
||||
const key = getOpenAiKey();
|
||||
if (!key) return { embedding: null, cached: false, model: null, error: "OPENAI_NO_KEY" };
|
||||
|
||||
const content = normalizeText(text);
|
||||
const contentHash = hashText(content);
|
||||
const cached = await getProductEmbedding({ tenant_id: tenantId, content_hash: contentHash });
|
||||
if (cached?.embedding) {
|
||||
return { embedding: cached.embedding, cached: true, model: cached.model || null };
|
||||
}
|
||||
|
||||
const client = new OpenAI({ apiKey: key });
|
||||
const model = getEmbeddingsModel();
|
||||
const resp = await client.embeddings.create({
|
||||
model,
|
||||
input: content,
|
||||
});
|
||||
const vector = resp?.data?.[0]?.embedding || null;
|
||||
if (Array.isArray(vector)) {
|
||||
await upsertProductEmbedding({
|
||||
tenant_id: tenantId,
|
||||
content_hash: contentHash,
|
||||
content_text: content,
|
||||
embedding: vector,
|
||||
model,
|
||||
});
|
||||
}
|
||||
return { embedding: vector, cached: false, model };
|
||||
}
|
||||
|
||||
function mergeCandidates(list) {
|
||||
const map = new Map();
|
||||
for (const c of list) {
|
||||
if (!c?.woo_product_id) continue;
|
||||
const id = Number(c.woo_product_id);
|
||||
if (!map.has(id)) {
|
||||
map.set(id, { ...c });
|
||||
} else {
|
||||
const prev = map.get(id);
|
||||
map.set(id, { ...prev, ...c, _score: Math.max(prev._score || 0, c._score || 0) });
|
||||
}
|
||||
}
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* retrieveCandidates: combina Woo literal + alias + embeddings.
|
||||
*/
|
||||
export async function retrieveCandidates({
|
||||
tenantId,
|
||||
query,
|
||||
attributes = [],
|
||||
preparation = [],
|
||||
limit = 12,
|
||||
}) {
|
||||
const lim = Math.max(1, Math.min(50, parseInt(limit, 10) || 12));
|
||||
const q = String(query || "").trim();
|
||||
if (!q) {
|
||||
return { candidates: [], audit: { reason: "empty_query" } };
|
||||
}
|
||||
|
||||
const audit = { query: q, sources: {}, boosts: {}, embeddings: {} };
|
||||
|
||||
const aliases = await searchProductAliases({ tenant_id: tenantId, q, limit: 20 });
|
||||
const aliasBoostByProduct = new Map();
|
||||
for (const a of aliases) {
|
||||
if (a?.woo_product_id) {
|
||||
const id = Number(a.woo_product_id);
|
||||
const boost = Number(a.boost || 0);
|
||||
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, boost || 0));
|
||||
}
|
||||
}
|
||||
audit.sources.aliases = aliases.length;
|
||||
|
||||
const { items: wooItems, source: wooSource } = await searchSnapshotItems({
|
||||
tenantId,
|
||||
q,
|
||||
limit: lim,
|
||||
});
|
||||
audit.sources.snapshot = { source: wooSource, count: wooItems?.length || 0 };
|
||||
|
||||
let candidates = (wooItems || []).map((c) => {
|
||||
const lit = literalScore(q, c);
|
||||
const boost = aliasBoostByProduct.get(Number(c.woo_product_id)) || 0;
|
||||
return { ...c, _score: lit + boost, _score_detail: { literal: lit, alias_boost: boost } };
|
||||
});
|
||||
|
||||
// embeddings: opcional, si hay key y tenemos candidatos
|
||||
if (candidates.length) {
|
||||
try {
|
||||
const queryEmb = await embedText({ tenantId, text: q });
|
||||
if (Array.isArray(queryEmb.embedding)) {
|
||||
audit.embeddings.query = { cached: queryEmb.cached, model: queryEmb.model };
|
||||
const enriched = [];
|
||||
for (const c of candidates.slice(0, 25)) {
|
||||
const text = candidateText(c);
|
||||
const emb = await embedText({ tenantId, text });
|
||||
const cos = Array.isArray(emb.embedding) ? cosine(queryEmb.embedding, emb.embedding) : 0;
|
||||
const prev = c._score || 0;
|
||||
enriched.push({
|
||||
...c,
|
||||
_score: prev + Math.max(0, cos),
|
||||
_score_detail: { ...(c._score_detail || {}), cosine: cos, emb_cached: emb.cached },
|
||||
});
|
||||
}
|
||||
// merge con el resto sin embeddings
|
||||
const tail = candidates.slice(25);
|
||||
candidates = mergeCandidates([...enriched, ...tail]);
|
||||
} else {
|
||||
audit.embeddings.query = { error: queryEmb.error || "no_embedding" };
|
||||
}
|
||||
} catch (e) {
|
||||
audit.embeddings.error = String(e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => (b._score || 0) - (a._score || 0));
|
||||
const finalList = candidates.slice(0, lim);
|
||||
|
||||
if (dbg.resolve) {
|
||||
console.log("[catalogRetrieval] candidates", {
|
||||
query: q,
|
||||
top: finalList.slice(0, 5).map((c) => ({
|
||||
id: c.woo_product_id,
|
||||
name: c.name,
|
||||
score: c._score,
|
||||
detail: c._score_detail,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return { candidates: finalList, audit };
|
||||
}
|
||||
|
||||
153
src/modules/3-turn-engine/fsm.js
Normal file
153
src/modules/3-turn-engine/fsm.js
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* FSM autoritativa (server-side) para el flujo conversacional.
|
||||
*
|
||||
* Principios:
|
||||
* - El LLM NO decide estados. Solo NLU.
|
||||
* - El backend deriva el estado objetivo a partir del contexto + acciones.
|
||||
* - Validamos transiciones y, si algo queda inconsistente, caemos a ERROR_RECOVERY.
|
||||
*/
|
||||
|
||||
export const ConversationState = Object.freeze({
|
||||
IDLE: "IDLE",
|
||||
BROWSING: "BROWSING",
|
||||
AWAITING_QUANTITY: "AWAITING_QUANTITY",
|
||||
CART_ACTIVE: "CART_ACTIVE",
|
||||
AWAITING_ADDRESS: "AWAITING_ADDRESS",
|
||||
AWAITING_PAYMENT: "AWAITING_PAYMENT",
|
||||
COMPLETED: "COMPLETED",
|
||||
ERROR_RECOVERY: "ERROR_RECOVERY",
|
||||
});
|
||||
|
||||
export const ALL_STATES = Object.freeze(Object.values(ConversationState));
|
||||
|
||||
function hasBasketItems(ctx) {
|
||||
const items = ctx?.basket?.items || ctx?.order_basket?.items;
|
||||
return Array.isArray(items) && items.length > 0;
|
||||
}
|
||||
|
||||
function hasPendingClarification(ctx) {
|
||||
const pc = ctx?.pending_clarification;
|
||||
return Boolean(pc?.candidates?.length) || Boolean(pc?.options?.length);
|
||||
}
|
||||
|
||||
function hasPendingItem(ctx) {
|
||||
return Boolean(ctx?.pending_item?.product_id || ctx?.pending_item?.sku);
|
||||
}
|
||||
|
||||
function hasAddress(ctx) {
|
||||
return Boolean(ctx?.delivery_address?.text || ctx?.address?.text || ctx?.address_text);
|
||||
}
|
||||
|
||||
function hasWooOrder(ctx) {
|
||||
return Boolean(ctx?.woo_order_id || ctx?.last_order_id);
|
||||
}
|
||||
|
||||
function hasPaymentLink(ctx) {
|
||||
return Boolean(ctx?.mp?.init_point || ctx?.payment?.init_point || ctx?.payment_link);
|
||||
}
|
||||
|
||||
function isPaid(ctx) {
|
||||
const st =
|
||||
ctx?.mp?.payment_status ||
|
||||
ctx?.payment?.status ||
|
||||
ctx?.payment_status ||
|
||||
null;
|
||||
return st === "approved" || st === "paid";
|
||||
}
|
||||
|
||||
/**
|
||||
* Deriva el estado objetivo según el contexto actual y señales del turno.
|
||||
* `signals` es información determinística del motor del turno (no del LLM),
|
||||
* por ejemplo: { requested_checkout: true }.
|
||||
*/
|
||||
export function deriveNextState(prevState, ctx = {}, signals = {}) {
|
||||
// Regla 1: pago confirmado gana siempre
|
||||
if (isPaid(ctx)) return ConversationState.COMPLETED;
|
||||
|
||||
// Regla 2: si ya existe orden + link de pago, estamos esperando pago
|
||||
if (hasWooOrder(ctx) && hasPaymentLink(ctx)) return ConversationState.AWAITING_PAYMENT;
|
||||
|
||||
// Regla 3: si intentó checkout pero falta dirección
|
||||
if ((signals.requested_checkout || signals.requested_address) && hasBasketItems(ctx) && !hasAddress(ctx)) {
|
||||
return ConversationState.AWAITING_ADDRESS;
|
||||
}
|
||||
|
||||
// Regla 4: si hay item pendiente sin completar cantidad
|
||||
if (hasPendingItem(ctx) && !signals.pending_item_completed) {
|
||||
return ConversationState.AWAITING_QUANTITY;
|
||||
}
|
||||
|
||||
// Regla 5: si hay carrito activo
|
||||
if (hasBasketItems(ctx)) return ConversationState.CART_ACTIVE;
|
||||
|
||||
// Regla 6: si estamos mostrando opciones / esperando selección
|
||||
if (hasPendingClarification(ctx) || signals.did_show_options || signals.is_browsing) {
|
||||
return ConversationState.BROWSING;
|
||||
}
|
||||
|
||||
return ConversationState.IDLE;
|
||||
}
|
||||
|
||||
const ALLOWED = Object.freeze({
|
||||
[ConversationState.IDLE]: [
|
||||
ConversationState.IDLE,
|
||||
ConversationState.BROWSING,
|
||||
ConversationState.AWAITING_QUANTITY,
|
||||
ConversationState.CART_ACTIVE,
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
],
|
||||
[ConversationState.BROWSING]: [
|
||||
ConversationState.BROWSING,
|
||||
ConversationState.AWAITING_QUANTITY,
|
||||
ConversationState.CART_ACTIVE,
|
||||
ConversationState.IDLE,
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
],
|
||||
[ConversationState.AWAITING_QUANTITY]: [
|
||||
ConversationState.AWAITING_QUANTITY,
|
||||
ConversationState.CART_ACTIVE,
|
||||
ConversationState.BROWSING,
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
],
|
||||
[ConversationState.CART_ACTIVE]: [
|
||||
ConversationState.CART_ACTIVE,
|
||||
ConversationState.AWAITING_ADDRESS,
|
||||
ConversationState.AWAITING_PAYMENT,
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
ConversationState.BROWSING,
|
||||
],
|
||||
[ConversationState.AWAITING_ADDRESS]: [
|
||||
ConversationState.AWAITING_ADDRESS,
|
||||
ConversationState.AWAITING_PAYMENT,
|
||||
ConversationState.CART_ACTIVE,
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
],
|
||||
[ConversationState.AWAITING_PAYMENT]: [
|
||||
ConversationState.AWAITING_PAYMENT,
|
||||
ConversationState.COMPLETED,
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
],
|
||||
[ConversationState.COMPLETED]: [
|
||||
ConversationState.COMPLETED,
|
||||
ConversationState.IDLE, // nueva conversación / reinicio natural
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
],
|
||||
[ConversationState.ERROR_RECOVERY]: ALL_STATES,
|
||||
});
|
||||
|
||||
export function validateTransition(prevState, nextState) {
|
||||
const p = prevState || ConversationState.IDLE;
|
||||
const n = nextState || ConversationState.IDLE;
|
||||
if (!ALLOWED[p]) return { ok: false, reason: "unknown_prev_state", prev: p, next: n };
|
||||
if (!ALL_STATES.includes(n)) return { ok: false, reason: "unknown_next_state", prev: p, next: n };
|
||||
const ok = ALLOWED[p].includes(n);
|
||||
return ok ? { ok: true } : { ok: false, reason: "invalid_transition", prev: p, next: n };
|
||||
}
|
||||
|
||||
export function safeNextState(prevState, ctx, signals) {
|
||||
const desired = deriveNextState(prevState, ctx, signals);
|
||||
const v = validateTransition(prevState, desired);
|
||||
if (v.ok) return { next_state: desired, validation: v };
|
||||
return { next_state: ConversationState.ERROR_RECOVERY, validation: v };
|
||||
}
|
||||
|
||||
203
src/modules/3-turn-engine/openai.js
Normal file
203
src/modules/3-turn-engine/openai.js
Normal file
@@ -0,0 +1,203 @@
|
||||
import OpenAI from "openai";
|
||||
import Ajv from "ajv";
|
||||
import { debug as dbg } from "../shared/debug.js";
|
||||
|
||||
let _client = null;
|
||||
let _clientKey = null;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function jsonCompletion({ system, user, model }) {
|
||||
const openai = getClient();
|
||||
const chosenModel = model || process.env.OPENAI_MODEL || "gpt-4o-mini";
|
||||
const debug = dbg.llm;
|
||||
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: 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 {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
const extracted = extractJsonObject(text);
|
||||
if (!extracted) throw new Error("openai_invalid_json");
|
||||
parsed = JSON.parse(extracted);
|
||||
}
|
||||
return { parsed, raw_text: text, model: chosenModel, usage: resp?.usage || null };
|
||||
}
|
||||
|
||||
// --- NLU v3 (single-step, schema-strict) ---
|
||||
|
||||
const NluV3JsonSchema = {
|
||||
$id: "NluV3",
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["intent", "confidence", "language", "entities", "needs"],
|
||||
properties: {
|
||||
intent: {
|
||||
type: "string",
|
||||
enum: ["price_query", "browse", "add_to_cart", "remove_from_cart", "checkout", "greeting", "other"],
|
||||
},
|
||||
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||
language: { type: "string" },
|
||||
entities: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["product_query", "quantity", "unit", "selection", "attributes", "preparation"],
|
||||
properties: {
|
||||
product_query: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
quantity: { anyOf: [{ type: "number" }, { type: "null" }] },
|
||||
unit: { anyOf: [{ type: "string", enum: ["kg", "g", "unidad"] }, { type: "null" }] },
|
||||
selection: {
|
||||
anyOf: [
|
||||
{ type: "null" },
|
||||
{
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["type", "value"],
|
||||
properties: {
|
||||
type: { type: "string", enum: ["index", "text", "sku"] },
|
||||
value: { type: "string", minLength: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
attributes: { type: "array", items: { type: "string" } },
|
||||
preparation: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
needs: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["catalog_lookup", "knowledge_lookup"],
|
||||
properties: {
|
||||
catalog_lookup: { type: "boolean" },
|
||||
knowledge_lookup: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ajv = new Ajv({ allErrors: true, strict: true });
|
||||
const validateNluV3 = ajv.compile(NluV3JsonSchema);
|
||||
|
||||
function nluV3Fallback() {
|
||||
return {
|
||||
intent: "other",
|
||||
confidence: 0,
|
||||
language: "es-AR",
|
||||
entities: {
|
||||
product_query: null,
|
||||
quantity: null,
|
||||
unit: null,
|
||||
selection: null,
|
||||
attributes: [],
|
||||
preparation: [],
|
||||
},
|
||||
needs: { catalog_lookup: false, knowledge_lookup: false },
|
||||
};
|
||||
}
|
||||
|
||||
function nluV3Errors() {
|
||||
const errs = validateNluV3.errors || [];
|
||||
return errs.map((e) => ({
|
||||
instancePath: e.instancePath,
|
||||
schemaPath: e.schemaPath,
|
||||
keyword: e.keyword,
|
||||
message: e.message,
|
||||
params: e.params,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function llmNluV3({ input, model } = {}) {
|
||||
const systemBase =
|
||||
"Sos un servicio NLU (es-AR). Extraés intención y entidades del mensaje del usuario.\n" +
|
||||
"IMPORTANTE:\n" +
|
||||
"- NO decidas estados (FSM), NO planifiques acciones, NO inventes productos ni precios.\n" +
|
||||
"- Respondé SOLO con JSON válido, EXACTAMENTE con las keys del contrato. additionalProperties=false.\n" +
|
||||
"- Si hay opciones mostradas y el usuario responde con un número/ordinal ('el segundo'), eso es entities.selection {type:'index'}.\n" +
|
||||
"- Si el usuario responde 'mostrame más', poné intent='browse' y entities.selection=null (la paginación la maneja el servidor).\n" +
|
||||
"- needs.catalog_lookup debe ser true para intents price_query|browse|add_to_cart si NO es una pura selección sobre opciones ya mostradas.\n";
|
||||
|
||||
const user = JSON.stringify(input ?? {});
|
||||
|
||||
// intento 1
|
||||
const first = await jsonCompletion({ system: systemBase, user, model });
|
||||
if (validateNluV3(first.parsed)) {
|
||||
return { nlu: first.parsed, raw_text: first.raw_text, model: first.model, usage: first.usage, schema: "v3", validation: { ok: true } };
|
||||
}
|
||||
|
||||
const errors1 = nluV3Errors();
|
||||
|
||||
// retry 1 vez
|
||||
const systemRetry =
|
||||
systemBase +
|
||||
"\nTu respuesta anterior no validó el JSON Schema. Corregí el JSON para que cumpla estrictamente.\n" +
|
||||
`Errores: ${JSON.stringify(errors1).slice(0, 1800)}\n`;
|
||||
|
||||
try {
|
||||
const second = await jsonCompletion({ system: systemRetry, user, model });
|
||||
if (validateNluV3(second.parsed)) {
|
||||
return { nlu: second.parsed, raw_text: second.raw_text, model: second.model, usage: second.usage, schema: "v3", validation: { ok: true, retried: true } };
|
||||
}
|
||||
const errors2 = nluV3Errors();
|
||||
return {
|
||||
nlu: nluV3Fallback(),
|
||||
raw_text: second.raw_text,
|
||||
model: second.model,
|
||||
usage: second.usage,
|
||||
schema: "v3",
|
||||
validation: { ok: false, retried: true, errors: errors2 },
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
nlu: nluV3Fallback(),
|
||||
raw_text: first.raw_text,
|
||||
model: first.model,
|
||||
usage: first.usage,
|
||||
schema: "v3",
|
||||
validation: { ok: false, retried: true, error: String(e?.message || e), errors: errors1 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy llmPlan/llmExtract y NLU v2 removidos.
|
||||
585
src/modules/3-turn-engine/turnEngineV3.js
Normal file
585
src/modules/3-turn-engine/turnEngineV3.js
Normal file
@@ -0,0 +1,585 @@
|
||||
import { llmNluV3 } from "./openai.js";
|
||||
import { retrieveCandidates } from "./catalogRetrieval.js";
|
||||
import { safeNextState } from "./fsm.js";
|
||||
|
||||
function unitAskFor(displayUnit) {
|
||||
if (displayUnit === "unit") return "¿Cuántas unidades querés?";
|
||||
if (displayUnit === "g") return "¿Cuántos gramos querés?";
|
||||
return "¿Cuántos kilos querés?";
|
||||
}
|
||||
|
||||
function unitDisplay(unit) {
|
||||
if (unit === "unit") return "unidades";
|
||||
if (unit === "g") return "gramos";
|
||||
return "kilos";
|
||||
}
|
||||
|
||||
function inferDefaultUnit({ name, categories }) {
|
||||
const n = String(name || "").toLowerCase();
|
||||
const cats = Array.isArray(categories) ? categories : [];
|
||||
const hay = (re) =>
|
||||
cats.some((c) => re.test(String(c?.name || "")) || re.test(String(c?.slug || ""))) || re.test(n);
|
||||
if (hay(/\b(vino|vinos|bebida|bebidas|cerveza|cervezas|gaseosa|gaseosas|whisky|ron|gin|vodka|fernet)\b/i)) {
|
||||
return "unit";
|
||||
}
|
||||
return "kg";
|
||||
}
|
||||
|
||||
function parseIndexSelection(text) {
|
||||
const t = String(text || "").toLowerCase();
|
||||
const m = /\b(\d{1,2})\b/.exec(t);
|
||||
if (m) return parseInt(m[1], 10);
|
||||
if (/\bprimera\b|\bprimero\b/.test(t)) return 1;
|
||||
if (/\bsegunda\b|\bsegundo\b/.test(t)) return 2;
|
||||
if (/\btercera\b|\btercero\b/.test(t)) return 3;
|
||||
if (/\bcuarta\b|\bcuarto\b/.test(t)) return 4;
|
||||
if (/\bquinta\b|\bquinto\b/.test(t)) return 5;
|
||||
if (/\bsexta\b|\bsexto\b/.test(t)) return 6;
|
||||
if (/\bs[eé]ptima\b|\bs[eé]ptimo\b/.test(t)) return 7;
|
||||
if (/\boctava\b|\boctavo\b/.test(t)) return 8;
|
||||
if (/\bnovena\b|\bnoveno\b/.test(t)) return 9;
|
||||
if (/\bd[eé]cima\b|\bd[eé]cimo\b/.test(t)) return 10;
|
||||
return null;
|
||||
}
|
||||
|
||||
function isShowMoreRequest(text) {
|
||||
const t = String(text || "").toLowerCase();
|
||||
return (
|
||||
/\bmostr(a|ame)\s+m[aá]s\b/.test(t) ||
|
||||
/\bmas\s+opciones\b/.test(t) ||
|
||||
(/\bm[aá]s\b/.test(t) && /\b(opciones|productos|variedades|tipos)\b/.test(t)) ||
|
||||
/\bsiguiente(s)?\b/.test(t)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeText(s) {
|
||||
return String(s || "")
|
||||
.toLowerCase()
|
||||
.replace(/[¿?¡!.,;:()"]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function scoreTextMatch(query, candidateName) {
|
||||
const qt = new Set(normalizeText(query).split(" ").filter(Boolean));
|
||||
const nt = new Set(normalizeText(candidateName).split(" ").filter(Boolean));
|
||||
let hits = 0;
|
||||
for (const w of qt) if (nt.has(w)) hits++;
|
||||
return hits / Math.max(qt.size, 1);
|
||||
}
|
||||
|
||||
function buildPagedOptions({ candidates, candidateOffset = 0, baseIdx = 1, pageSize = 9 }) {
|
||||
const cands = (candidates || []).filter((c) => c && c.woo_product_id && c.name);
|
||||
const off = Math.max(0, parseInt(candidateOffset, 10) || 0);
|
||||
const size = Math.max(1, Math.min(20, parseInt(pageSize, 10) || 9));
|
||||
const slice = cands.slice(off, off + size);
|
||||
const options = slice.map((c, i) => ({
|
||||
idx: baseIdx + i,
|
||||
type: "product",
|
||||
woo_product_id: c.woo_product_id,
|
||||
name: c.name,
|
||||
}));
|
||||
const hasMore = off + size < cands.length;
|
||||
if (hasMore) options.push({ idx: baseIdx + size, type: "more", name: "Mostrame más…" });
|
||||
const list = options
|
||||
.map((o) => (o.type === "more" ? `- ${o.idx}) ${o.name}` : `- ${o.idx}) ${o.name}`))
|
||||
.join("\n");
|
||||
const question = `¿Cuál de estos querés?\n${list}\n\nRespondé con el número.`;
|
||||
const pending = {
|
||||
candidates: cands,
|
||||
options,
|
||||
candidate_offset: off,
|
||||
page_size: size,
|
||||
base_idx: baseIdx,
|
||||
has_more: hasMore,
|
||||
next_candidate_offset: off + size,
|
||||
next_base_idx: baseIdx + size + (hasMore ? 1 : 0),
|
||||
};
|
||||
return { question, pending, options, hasMore };
|
||||
}
|
||||
|
||||
function resolvePendingSelection({ text, nlu, pending }) {
|
||||
if (!pending?.candidates?.length) return { kind: "none" };
|
||||
|
||||
if (isShowMoreRequest(text)) {
|
||||
const { question, pending: nextPending } = buildPagedOptions({
|
||||
candidates: pending.candidates,
|
||||
candidateOffset: pending.next_candidate_offset ?? ((pending.candidate_offset || 0) + (pending.page_size || 9)),
|
||||
baseIdx: pending.next_base_idx ?? ((pending.base_idx || 1) + (pending.page_size || 9) + 1),
|
||||
pageSize: pending.page_size || 9,
|
||||
});
|
||||
return { kind: "more", question, pending: nextPending };
|
||||
}
|
||||
|
||||
const idx =
|
||||
(nlu?.entities?.selection?.type === "index" ? parseInt(String(nlu.entities.selection.value), 10) : null) ??
|
||||
parseIndexSelection(text);
|
||||
if (idx && Array.isArray(pending.options)) {
|
||||
const opt = pending.options.find((o) => o.idx === idx);
|
||||
if (opt?.type === "more") return { kind: "more", question: null, pending };
|
||||
if (opt?.woo_product_id) {
|
||||
const chosen = pending.candidates.find((c) => c.woo_product_id === opt.woo_product_id) || null;
|
||||
if (chosen) return { kind: "chosen", chosen };
|
||||
}
|
||||
}
|
||||
|
||||
const selText = nlu?.entities?.selection?.type === "text"
|
||||
? String(nlu.entities.selection.value || "").trim()
|
||||
: null;
|
||||
const q = selText || nlu?.entities?.product_query || null;
|
||||
if (q) {
|
||||
const scored = pending.candidates
|
||||
.map((c) => ({ c, s: scoreTextMatch(q, c?.name) }))
|
||||
.sort((a, b) => b.s - a.s);
|
||||
if (scored[0]?.s >= 0.6 && (!scored[1] || scored[0].s - scored[1].s >= 0.2)) {
|
||||
return { kind: "chosen", chosen: scored[0].c };
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "ask" };
|
||||
}
|
||||
|
||||
function normalizeUnit(unit) {
|
||||
if (!unit) return null;
|
||||
const u = String(unit).toLowerCase();
|
||||
if (u === "kg" || u === "kilo" || u === "kilos") return "kg";
|
||||
if (u === "g" || u === "gramo" || u === "gramos") return "g";
|
||||
if (u === "unidad" || u === "unidades" || u === "unit") return "unit";
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveQuantity({ quantity, unit, displayUnit }) {
|
||||
if (quantity == null || !Number.isFinite(Number(quantity)) || Number(quantity) <= 0) return null;
|
||||
const q = Number(quantity);
|
||||
const u = normalizeUnit(unit) || (displayUnit === "unit" ? "unit" : displayUnit === "g" ? "g" : "kg");
|
||||
if (u === "unit") return { quantity: Math.round(q), unit: "unit", display_quantity: Math.round(q), display_unit: "unit" };
|
||||
if (u === "g") return { quantity: Math.round(q), unit: "g", display_quantity: Math.round(q), display_unit: "g" };
|
||||
// kg -> gramos enteros
|
||||
return {
|
||||
quantity: Math.round(q * 1000),
|
||||
unit: "g",
|
||||
display_unit: "kg",
|
||||
display_quantity: q,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPendingItemFromCandidate(candidate) {
|
||||
const displayUnit = inferDefaultUnit({ name: candidate.name, categories: candidate.categories });
|
||||
return {
|
||||
product_id: Number(candidate.woo_product_id),
|
||||
variation_id: null,
|
||||
name: candidate.name,
|
||||
price: candidate.price ?? null,
|
||||
categories: candidate.categories || [],
|
||||
attributes: candidate.attributes || [],
|
||||
display_unit: displayUnit,
|
||||
};
|
||||
}
|
||||
|
||||
function askClarificationReply() {
|
||||
return "Dale, ¿qué producto querés exactamente?";
|
||||
}
|
||||
|
||||
function shortSummary(history) {
|
||||
if (!Array.isArray(history)) return "";
|
||||
return history
|
||||
.slice(-5)
|
||||
.map((m) => `${m.role === "user" ? "U" : "A"}:${String(m.content || "").slice(0, 120)}`)
|
||||
.join(" | ");
|
||||
}
|
||||
|
||||
function hasAddress(ctx) {
|
||||
return Boolean(ctx?.delivery_address?.text || ctx?.address?.text || ctx?.address_text);
|
||||
}
|
||||
|
||||
export async function runTurnV3({
|
||||
tenantId,
|
||||
chat_id,
|
||||
text,
|
||||
prev_state,
|
||||
prev_context,
|
||||
conversation_history,
|
||||
tenant_config = {},
|
||||
} = {}) {
|
||||
const prev = prev_context && typeof prev_context === "object" ? prev_context : {};
|
||||
const actions = [];
|
||||
const context_patch = {};
|
||||
const audit = {};
|
||||
|
||||
const last_shown_options = Array.isArray(prev?.pending_clarification?.options)
|
||||
? prev.pending_clarification.options.map((o) => ({ idx: o.idx, type: o.type, name: o.name, woo_product_id: o.woo_product_id || null }))
|
||||
: [];
|
||||
|
||||
const nluInput = {
|
||||
last_user_message: text,
|
||||
conversation_state: prev_state || "IDLE",
|
||||
memory_summary: shortSummary(conversation_history),
|
||||
pending_context: {
|
||||
pending_clarification: Boolean(prev?.pending_clarification?.candidates?.length),
|
||||
pending_item: prev?.pending_item?.name || null,
|
||||
},
|
||||
last_shown_options,
|
||||
locale: tenant_config?.locale || "es-AR",
|
||||
};
|
||||
|
||||
const { nlu, raw_text, model, usage, validation } = await llmNluV3({ input: nluInput });
|
||||
audit.nlu = { raw_text, model, usage, validation, parsed: nlu };
|
||||
|
||||
// 1) Resolver pending_clarification primero
|
||||
if (prev?.pending_clarification?.candidates?.length) {
|
||||
const resolved = resolvePendingSelection({ text, nlu, pending: prev.pending_clarification });
|
||||
if (resolved.kind === "more") {
|
||||
const nextPending = resolved.pending || prev.pending_clarification;
|
||||
const reply = resolved.question || buildPagedOptions({ candidates: nextPending.candidates }).question;
|
||||
context_patch.pending_clarification = nextPending;
|
||||
context_patch.pending_item = null;
|
||||
actions.push({ type: "show_options", payload: { count: nextPending.options?.length || 0 } });
|
||||
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { did_show_options: true });
|
||||
return {
|
||||
plan: {
|
||||
reply,
|
||||
next_state,
|
||||
intent: "browse",
|
||||
missing_fields: ["product_selection"],
|
||||
order_action: "none",
|
||||
basket_resolved: { items: [] },
|
||||
},
|
||||
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
|
||||
};
|
||||
}
|
||||
if (resolved.kind === "chosen" && resolved.chosen) {
|
||||
const pendingItem = buildPendingItemFromCandidate(resolved.chosen);
|
||||
const qty = resolveQuantity({
|
||||
quantity: nlu?.entities?.quantity,
|
||||
unit: nlu?.entities?.unit,
|
||||
displayUnit: pendingItem.display_unit,
|
||||
});
|
||||
if (qty?.quantity) {
|
||||
const item = {
|
||||
product_id: pendingItem.product_id,
|
||||
variation_id: pendingItem.variation_id,
|
||||
quantity: qty.quantity,
|
||||
unit: qty.unit,
|
||||
label: pendingItem.name,
|
||||
};
|
||||
const prevItems = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items : [];
|
||||
context_patch.order_basket = { items: [...prevItems, item] };
|
||||
context_patch.pending_item = null;
|
||||
context_patch.pending_clarification = null;
|
||||
actions.push({ type: "add_to_cart", payload: item });
|
||||
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { pending_item_completed: true });
|
||||
const display = qty.display_unit === "kg"
|
||||
? `${qty.display_quantity}kg`
|
||||
: qty.display_unit === "unit"
|
||||
? `${qty.display_quantity}u`
|
||||
: `${qty.display_quantity}g`;
|
||||
return {
|
||||
plan: {
|
||||
reply: `Perfecto, anoto ${display} de ${pendingItem.name}. ¿Algo más?`,
|
||||
next_state,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
basket_resolved: { items: [item] },
|
||||
},
|
||||
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
|
||||
};
|
||||
}
|
||||
context_patch.pending_item = pendingItem;
|
||||
context_patch.pending_clarification = null;
|
||||
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, {});
|
||||
return {
|
||||
plan: {
|
||||
reply: unitAskFor(pendingItem.display_unit === "unit" ? "unit" : "kg"),
|
||||
next_state,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: ["quantity"],
|
||||
order_action: "none",
|
||||
basket_resolved: { items: [] },
|
||||
},
|
||||
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
|
||||
};
|
||||
}
|
||||
const { question, pending } = buildPagedOptions({ candidates: prev.pending_clarification.candidates });
|
||||
context_patch.pending_clarification = pending;
|
||||
actions.push({ type: "show_options", payload: { count: pending.options?.length || 0 } });
|
||||
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { did_show_options: true });
|
||||
return {
|
||||
plan: {
|
||||
reply: question,
|
||||
next_state,
|
||||
intent: "browse",
|
||||
missing_fields: ["product_selection"],
|
||||
order_action: "none",
|
||||
basket_resolved: { items: [] },
|
||||
},
|
||||
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
|
||||
};
|
||||
}
|
||||
|
||||
// 2) Si hay pending_item, esperamos cantidad
|
||||
if (prev?.pending_item?.product_id) {
|
||||
const pendingItem = prev.pending_item;
|
||||
const qty = resolveQuantity({
|
||||
quantity: nlu?.entities?.quantity,
|
||||
unit: nlu?.entities?.unit,
|
||||
displayUnit: pendingItem.display_unit || "kg",
|
||||
});
|
||||
if (qty?.quantity) {
|
||||
const item = {
|
||||
product_id: Number(pendingItem.product_id),
|
||||
variation_id: pendingItem.variation_id ?? null,
|
||||
quantity: qty.quantity,
|
||||
unit: qty.unit,
|
||||
label: pendingItem.name || "ese producto",
|
||||
};
|
||||
const prevItems = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items : [];
|
||||
context_patch.order_basket = { items: [...prevItems, item] };
|
||||
context_patch.pending_item = null;
|
||||
actions.push({ type: "add_to_cart", payload: item });
|
||||
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { pending_item_completed: true });
|
||||
const display = qty.display_unit === "kg"
|
||||
? `${qty.display_quantity}kg`
|
||||
: qty.display_unit === "unit"
|
||||
? `${qty.display_quantity}u`
|
||||
: `${qty.display_quantity}g`;
|
||||
return {
|
||||
plan: {
|
||||
reply: `Perfecto, anoto ${display} de ${item.label}. ¿Algo más?`,
|
||||
next_state,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
basket_resolved: { items: [item] },
|
||||
},
|
||||
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
|
||||
};
|
||||
}
|
||||
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {});
|
||||
return {
|
||||
plan: {
|
||||
reply: unitAskFor(pendingItem.display_unit === "unit" ? "unit" : "kg"),
|
||||
next_state,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: ["quantity"],
|
||||
order_action: "none",
|
||||
basket_resolved: { items: [] },
|
||||
},
|
||||
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
|
||||
};
|
||||
}
|
||||
|
||||
// 3) Intento normal
|
||||
const intent = nlu?.intent || "other";
|
||||
const productQuery = String(nlu?.entities?.product_query || "").trim();
|
||||
const needsCatalog = Boolean(nlu?.needs?.catalog_lookup);
|
||||
|
||||
if (intent === "greeting") {
|
||||
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {});
|
||||
return {
|
||||
plan: {
|
||||
reply: "¡Hola! ¿Qué te gustaría pedir hoy?",
|
||||
next_state,
|
||||
intent: "greeting",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
basket_resolved: { items: [] },
|
||||
},
|
||||
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "checkout") {
|
||||
const basketItems = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items : [];
|
||||
if (!basketItems.length) {
|
||||
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {});
|
||||
return {
|
||||
plan: {
|
||||
reply: "Para avanzar necesito al menos un producto. ¿Qué querés pedir?",
|
||||
next_state,
|
||||
intent: "checkout",
|
||||
missing_fields: ["basket_items"],
|
||||
order_action: "none",
|
||||
basket_resolved: { items: [] },
|
||||
},
|
||||
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
|
||||
};
|
||||
}
|
||||
if (!hasAddress(prev)) {
|
||||
actions.push({ type: "ask_address", payload: {} });
|
||||
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, { requested_checkout: true });
|
||||
return {
|
||||
plan: {
|
||||
reply: "Perfecto. ¿Me pasás la dirección de entrega?",
|
||||
next_state,
|
||||
intent: "checkout",
|
||||
missing_fields: ["address"],
|
||||
order_action: "checkout",
|
||||
basket_resolved: { items: basketItems },
|
||||
},
|
||||
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
|
||||
};
|
||||
}
|
||||
actions.push({ type: "create_order", payload: {} });
|
||||
actions.push({ type: "send_payment_link", payload: {} });
|
||||
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, { requested_checkout: true });
|
||||
return {
|
||||
plan: {
|
||||
reply: "Genial, ya genero el link de pago y te lo paso.",
|
||||
next_state,
|
||||
intent: "checkout",
|
||||
missing_fields: [],
|
||||
order_action: "checkout",
|
||||
basket_resolved: { items: basketItems },
|
||||
},
|
||||
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
|
||||
};
|
||||
}
|
||||
|
||||
if (needsCatalog && !productQuery) {
|
||||
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {});
|
||||
return {
|
||||
plan: {
|
||||
reply: askClarificationReply(),
|
||||
next_state,
|
||||
intent,
|
||||
missing_fields: ["product_query"],
|
||||
order_action: "none",
|
||||
basket_resolved: { items: [] },
|
||||
},
|
||||
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
|
||||
};
|
||||
}
|
||||
|
||||
if (needsCatalog) {
|
||||
const { candidates, audit: catAudit } = await retrieveCandidates({
|
||||
tenantId,
|
||||
query: productQuery,
|
||||
attributes: nlu?.entities?.attributes || [],
|
||||
preparation: nlu?.entities?.preparation || [],
|
||||
limit: 12,
|
||||
});
|
||||
audit.catalog = catAudit;
|
||||
|
||||
if (!candidates.length) {
|
||||
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {});
|
||||
return {
|
||||
plan: {
|
||||
reply: `No encontré "${productQuery}" en el catálogo. ¿Podés decirme el nombre exacto o un corte similar?`,
|
||||
next_state,
|
||||
intent,
|
||||
missing_fields: ["product_query"],
|
||||
order_action: "none",
|
||||
basket_resolved: { items: [] },
|
||||
},
|
||||
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
|
||||
};
|
||||
}
|
||||
|
||||
const best = candidates[0];
|
||||
const second = candidates[1];
|
||||
const strong = candidates.length === 1 || (best?._score >= 0.9 && (!second || best._score - (second?._score || 0) >= 0.2));
|
||||
|
||||
if (!strong) {
|
||||
const { question, pending } = buildPagedOptions({ candidates });
|
||||
context_patch.pending_clarification = pending;
|
||||
context_patch.pending_item = null;
|
||||
actions.push({ type: "show_options", payload: { count: pending.options?.length || 0 } });
|
||||
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { did_show_options: true });
|
||||
return {
|
||||
plan: {
|
||||
reply: question,
|
||||
next_state,
|
||||
intent: "browse",
|
||||
missing_fields: ["product_selection"],
|
||||
order_action: "none",
|
||||
basket_resolved: { items: [] },
|
||||
},
|
||||
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
|
||||
};
|
||||
}
|
||||
|
||||
const pendingItem = buildPendingItemFromCandidate(best);
|
||||
const qty = resolveQuantity({
|
||||
quantity: nlu?.entities?.quantity,
|
||||
unit: nlu?.entities?.unit,
|
||||
displayUnit: pendingItem.display_unit,
|
||||
});
|
||||
|
||||
if (intent === "price_query") {
|
||||
context_patch.pending_item = pendingItem;
|
||||
const price = best.price != null ? `está $${best.price} ${pendingItem.display_unit === "unit" ? "por unidad" : "el kilo"}` : "no tengo el precio confirmado ahora";
|
||||
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, {});
|
||||
return {
|
||||
plan: {
|
||||
reply: `${best.name} ${price}. ${unitAskFor(pendingItem.display_unit === "unit" ? "unit" : "kg")}`,
|
||||
next_state,
|
||||
intent: "price_query",
|
||||
missing_fields: ["quantity"],
|
||||
order_action: "none",
|
||||
basket_resolved: { items: [] },
|
||||
},
|
||||
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "add_to_cart" && qty?.quantity) {
|
||||
const item = {
|
||||
product_id: pendingItem.product_id,
|
||||
variation_id: pendingItem.variation_id,
|
||||
quantity: qty.quantity,
|
||||
unit: qty.unit,
|
||||
label: pendingItem.name,
|
||||
};
|
||||
const prevItems = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items : [];
|
||||
context_patch.order_basket = { items: [...prevItems, item] };
|
||||
actions.push({ type: "add_to_cart", payload: item });
|
||||
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { pending_item_completed: true });
|
||||
const display = qty.display_unit === "kg"
|
||||
? `${qty.display_quantity}kg`
|
||||
: qty.display_unit === "unit"
|
||||
? `${qty.display_quantity}u`
|
||||
: `${qty.display_quantity}g`;
|
||||
return {
|
||||
plan: {
|
||||
reply: `Perfecto, anoto ${display} de ${pendingItem.name}. ¿Algo más?`,
|
||||
next_state,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
basket_resolved: { items: [item] },
|
||||
},
|
||||
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
|
||||
};
|
||||
}
|
||||
|
||||
context_patch.pending_item = pendingItem;
|
||||
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, {});
|
||||
return {
|
||||
plan: {
|
||||
reply: unitAskFor(pendingItem.display_unit === "unit" ? "unit" : "kg"),
|
||||
next_state,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: ["quantity"],
|
||||
order_action: "none",
|
||||
basket_resolved: { items: [] },
|
||||
},
|
||||
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback seguro
|
||||
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {});
|
||||
return {
|
||||
plan: {
|
||||
reply: "Dale, ¿qué necesitás exactamente?",
|
||||
next_state,
|
||||
intent: "other",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
basket_resolved: { items: [] },
|
||||
},
|
||||
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
|
||||
};
|
||||
}
|
||||
|
||||
238
src/modules/4-woo-orders/wooOrders.js
Normal file
238
src/modules/4-woo-orders/wooOrders.js
Normal file
@@ -0,0 +1,238 @@
|
||||
import { getDecryptedTenantEcommerceConfig } from "../2-identity/db/repo.js";
|
||||
import { debug as dbg } from "../shared/debug.js";
|
||||
import { getSnapshotPriceByWooId } from "../shared/wooSnapshot.js";
|
||||
|
||||
// --- Simple in-memory lock to serialize work per key ---
|
||||
const locks = new Map();
|
||||
|
||||
async function withLock(key, fn) {
|
||||
const prev = locks.get(key) || Promise.resolve();
|
||||
let release;
|
||||
const next = new Promise((r) => (release = r));
|
||||
locks.set(key, prev.then(() => next));
|
||||
await prev;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release();
|
||||
if (locks.get(key) === next) locks.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWoo({ url, method = "GET", body = null, timeout = 20000, 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,
|
||||
});
|
||||
if (dbg.wooHttp) console.log("[wooOrders] http", method, res.status, Date.now() - t0, "ms");
|
||||
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;
|
||||
err.status = e?.status || null;
|
||||
err.body = e?.body || null;
|
||||
err.url = url;
|
||||
err.method = method;
|
||||
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 ?? 20000, 20000),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePrice(p) {
|
||||
if (p == null) return null;
|
||||
const n = Number(String(p).replace(",", "."));
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
async function getWooProductPrice({ tenantId, productId }) {
|
||||
if (!productId) return null;
|
||||
const snap = await getSnapshotPriceByWooId({ tenantId, wooId: productId });
|
||||
if (snap != null) return Number(snap);
|
||||
const client = await getWooClient({ tenantId });
|
||||
const url = `${client.base}/products/${encodeURIComponent(productId)}`;
|
||||
const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
|
||||
return parsePrice(data?.price ?? data?.regular_price ?? data?.sale_price);
|
||||
}
|
||||
|
||||
function normalizeBasketItems(basket) {
|
||||
const items = Array.isArray(basket?.items) ? basket.items : [];
|
||||
return items.filter((it) => it && it.product_id && it.quantity && it.unit);
|
||||
}
|
||||
|
||||
function toMoney(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
return (Math.round(n * 100) / 100).toFixed(2);
|
||||
}
|
||||
|
||||
async function buildLineItems({ tenantId, basket }) {
|
||||
const items = normalizeBasketItems(basket);
|
||||
const lineItems = [];
|
||||
for (const it of items) {
|
||||
const productId = Number(it.product_id);
|
||||
const unit = String(it.unit);
|
||||
const qty = Number(it.quantity);
|
||||
if (!productId || !Number.isFinite(qty) || qty <= 0) continue;
|
||||
const pricePerKg = await getWooProductPrice({ tenantId, productId });
|
||||
|
||||
if (unit === "unit") {
|
||||
const total = pricePerKg != null ? toMoney(pricePerKg * qty) : null;
|
||||
lineItems.push({
|
||||
product_id: productId,
|
||||
variation_id: it.variation_id ?? null,
|
||||
quantity: Math.round(qty),
|
||||
...(total ? { subtotal: total, total } : {}),
|
||||
meta_data: [
|
||||
{ key: "unit", value: "unit" },
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Carne por gramos (enteros): quantity=1 y total calculado en base a ARS/kg
|
||||
const grams = Math.round(qty);
|
||||
const kilos = grams / 1000;
|
||||
const total = pricePerKg != null ? toMoney(pricePerKg * kilos) : null;
|
||||
lineItems.push({
|
||||
product_id: productId,
|
||||
variation_id: it.variation_id ?? null,
|
||||
quantity: 1,
|
||||
...(total ? { subtotal: total, total } : {}),
|
||||
meta_data: [
|
||||
{ key: "unit", value: "g" },
|
||||
{ key: "weight_g", value: grams },
|
||||
{ key: "unit_price_per_kg", value: pricePerKg },
|
||||
],
|
||||
});
|
||||
}
|
||||
return lineItems;
|
||||
}
|
||||
|
||||
function mapAddress(address) {
|
||||
if (!address || typeof address !== "object") return null;
|
||||
return {
|
||||
first_name: address.first_name || "",
|
||||
last_name: address.last_name || "",
|
||||
address_1: address.address_1 || address.text || "",
|
||||
address_2: address.address_2 || "",
|
||||
city: address.city || "",
|
||||
state: address.state || "",
|
||||
postcode: address.postcode || "",
|
||||
country: address.country || "AR",
|
||||
phone: address.phone || "",
|
||||
email: address.email || "",
|
||||
};
|
||||
}
|
||||
|
||||
export async function createOrder({ tenantId, wooCustomerId, basket, address, run_id }) {
|
||||
const lockKey = `${tenantId}:${wooCustomerId || "anon"}`;
|
||||
return withLock(lockKey, async () => {
|
||||
const client = await getWooClient({ tenantId });
|
||||
const lineItems = await buildLineItems({ tenantId, basket });
|
||||
if (!lineItems.length) throw new Error("order_empty_basket");
|
||||
const addr = mapAddress(address);
|
||||
const payload = {
|
||||
status: "pending",
|
||||
customer_id: wooCustomerId || undefined,
|
||||
line_items: lineItems,
|
||||
...(addr ? { billing: addr, shipping: addr } : {}),
|
||||
meta_data: [
|
||||
{ key: "source", value: "whatsapp" },
|
||||
...(run_id ? [{ key: "run_id", value: run_id }] : []),
|
||||
],
|
||||
};
|
||||
const url = `${client.base}/orders`;
|
||||
const data = await fetchWoo({ url, method: "POST", body: payload, timeout: client.timeout, headers: client.authHeader });
|
||||
return { id: data?.id || null, raw: data, line_items: lineItems };
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateOrder({ tenantId, wooOrderId, basket, address, run_id }) {
|
||||
if (!wooOrderId) throw new Error("missing_woo_order_id");
|
||||
const lockKey = `${tenantId}:order:${wooOrderId}`;
|
||||
return withLock(lockKey, async () => {
|
||||
const client = await getWooClient({ tenantId });
|
||||
const lineItems = await buildLineItems({ tenantId, basket });
|
||||
if (!lineItems.length) throw new Error("order_empty_basket");
|
||||
const addr = mapAddress(address);
|
||||
const payload = {
|
||||
line_items: lineItems,
|
||||
...(addr ? { billing: addr, shipping: addr } : {}),
|
||||
meta_data: [
|
||||
{ key: "source", value: "whatsapp" },
|
||||
...(run_id ? [{ key: "run_id", value: run_id }] : []),
|
||||
],
|
||||
};
|
||||
const url = `${client.base}/orders/${encodeURIComponent(wooOrderId)}`;
|
||||
const data = await fetchWoo({ url, method: "PUT", body: payload, timeout: client.timeout, headers: client.authHeader });
|
||||
return { id: data?.id || wooOrderId, raw: data, line_items: lineItems };
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateOrderStatus({ tenantId, wooOrderId, status }) {
|
||||
if (!wooOrderId) throw new Error("missing_woo_order_id");
|
||||
const lockKey = `${tenantId}:order:${wooOrderId}:status`;
|
||||
return withLock(lockKey, async () => {
|
||||
const client = await getWooClient({ tenantId });
|
||||
const payload = { status };
|
||||
const url = `${client.base}/orders/${encodeURIComponent(wooOrderId)}`;
|
||||
const data = await fetchWoo({ url, method: "PUT", body: payload, timeout: client.timeout, headers: client.authHeader });
|
||||
return { id: data?.id || wooOrderId, raw: data };
|
||||
});
|
||||
}
|
||||
|
||||
42
src/modules/6-mercadopago/controllers/mercadoPago.js
Normal file
42
src/modules/6-mercadopago/controllers/mercadoPago.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { fetchPayment, reconcilePayment, verifyWebhookSignature } from "../mercadoPago.js";
|
||||
|
||||
export function makeMercadoPagoWebhook() {
|
||||
return async function handleMercadoPagoWebhook(req, res) {
|
||||
try {
|
||||
const signature = verifyWebhookSignature({ headers: req.headers, query: req.query || {} });
|
||||
if (!signature.ok) {
|
||||
return res.status(401).json({ ok: false, error: "invalid_signature", reason: signature.reason });
|
||||
}
|
||||
|
||||
const paymentId =
|
||||
req?.query?.["data.id"] ||
|
||||
req?.query?.data?.id ||
|
||||
req?.body?.data?.id ||
|
||||
null;
|
||||
|
||||
if (!paymentId) {
|
||||
return res.status(400).json({ ok: false, error: "missing_payment_id" });
|
||||
}
|
||||
|
||||
const payment = await fetchPayment({ paymentId });
|
||||
const reconciled = await reconcilePayment({ payment });
|
||||
|
||||
return res.status(200).json({
|
||||
ok: true,
|
||||
payment_id: payment?.id || null,
|
||||
status: payment?.status || null,
|
||||
woo_order_id: reconciled?.woo_order_id || null,
|
||||
});
|
||||
} catch (e) {
|
||||
return res.status(500).json({ ok: false, error: String(e?.message || e) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function makeMercadoPagoReturn() {
|
||||
return function handleMercadoPagoReturn(req, res) {
|
||||
const status = req.query?.status || "unknown";
|
||||
res.status(200).send(`OK - ${status}`);
|
||||
};
|
||||
}
|
||||
|
||||
178
src/modules/6-mercadopago/mercadoPago.js
Normal file
178
src/modules/6-mercadopago/mercadoPago.js
Normal file
@@ -0,0 +1,178 @@
|
||||
import crypto from "crypto";
|
||||
import { upsertMpPayment } from "../2-identity/db/repo.js";
|
||||
import { updateOrderStatus } from "../4-woo-orders/wooOrders.js";
|
||||
|
||||
function getAccessToken() {
|
||||
return process.env.MP_ACCESS_TOKEN || null;
|
||||
}
|
||||
|
||||
function getWebhookSecret() {
|
||||
return process.env.MP_WEBHOOK_SECRET || null;
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(base) {
|
||||
if (!base) return null;
|
||||
return base.endsWith("/") ? base : `${base}/`;
|
||||
}
|
||||
|
||||
function getBaseUrl() {
|
||||
return normalizeBaseUrl(process.env.MP_BASE_URL || process.env.MP_WEBHOOK_BASE_URL || null);
|
||||
}
|
||||
|
||||
async function fetchMp({ url, method = "GET", body = null }) {
|
||||
const token = getAccessToken();
|
||||
if (!token) throw new Error("MP_ACCESS_TOKEN is not set");
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let parsed;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
parsed = text;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = new Error(`MP HTTP ${res.status}`);
|
||||
err.status = res.status;
|
||||
err.body = parsed;
|
||||
throw err;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function createPreference({
|
||||
tenantId,
|
||||
wooOrderId,
|
||||
amount,
|
||||
payer = null,
|
||||
items = null,
|
||||
baseUrl = null,
|
||||
}) {
|
||||
const root = normalizeBaseUrl(baseUrl || getBaseUrl());
|
||||
if (!root) throw new Error("MP_BASE_URL is not set");
|
||||
const notificationUrl = `${root}webhook/mercadopago`;
|
||||
const backUrls = {
|
||||
success: `${root}return?status=success`,
|
||||
failure: `${root}return?status=failure`,
|
||||
pending: `${root}return?status=pending`,
|
||||
};
|
||||
const statementDescriptor = process.env.MP_STATEMENT_DESCRIPTOR || "Whatsapp Store";
|
||||
const externalReference = `${tenantId}|${wooOrderId}`;
|
||||
const unitPrice = Number(amount);
|
||||
if (!Number.isFinite(unitPrice)) throw new Error("invalid_amount");
|
||||
|
||||
const payload = {
|
||||
auto_return: "approved",
|
||||
back_urls: backUrls,
|
||||
statement_descriptor: statementDescriptor,
|
||||
binary_mode: false,
|
||||
external_reference: externalReference,
|
||||
items: Array.isArray(items) && items.length
|
||||
? items
|
||||
: [
|
||||
{
|
||||
id: String(wooOrderId || "order"),
|
||||
title: "Productos x whatsapp",
|
||||
quantity: 1,
|
||||
currency_id: "ARS",
|
||||
unit_price: unitPrice,
|
||||
},
|
||||
],
|
||||
notification_url: notificationUrl,
|
||||
...(payer ? { payer } : {}),
|
||||
};
|
||||
|
||||
const data = await fetchMp({
|
||||
url: "https://api.mercadopago.com/checkout/preferences",
|
||||
method: "POST",
|
||||
body: payload,
|
||||
});
|
||||
|
||||
return {
|
||||
preference_id: data?.id || null,
|
||||
init_point: data?.init_point || null,
|
||||
sandbox_init_point: data?.sandbox_init_point || null,
|
||||
raw: data,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSignatureHeader(header) {
|
||||
const h = String(header || "");
|
||||
const parts = h.split(",");
|
||||
let ts = null;
|
||||
let v1 = null;
|
||||
for (const p of parts) {
|
||||
const [k, v] = p.split("=");
|
||||
if (!k || !v) continue;
|
||||
const key = k.trim();
|
||||
const val = v.trim();
|
||||
if (key === "ts") ts = val;
|
||||
if (key === "v1") v1 = val;
|
||||
}
|
||||
return { ts, v1 };
|
||||
}
|
||||
|
||||
export function verifyWebhookSignature({ headers = {}, query = {} }) {
|
||||
const secret = getWebhookSecret();
|
||||
if (!secret) return { ok: false, reason: "MP_WEBHOOK_SECRET is not set" };
|
||||
const xSignature = headers["x-signature"] || headers["X-Signature"] || headers["x-signature"];
|
||||
const xRequestId = headers["x-request-id"] || headers["X-Request-Id"] || headers["x-request-id"];
|
||||
const { ts, v1 } = parseSignatureHeader(xSignature);
|
||||
const dataId = query["data.id"] || query?.data?.id || null;
|
||||
if (!xRequestId || !ts || !v1 || !dataId) {
|
||||
return { ok: false, reason: "missing_signature_fields" };
|
||||
}
|
||||
const manifest = `id:${String(dataId).toLowerCase()};request-id:${xRequestId};ts:${ts};`;
|
||||
const hmac = crypto.createHmac("sha256", secret);
|
||||
hmac.update(manifest);
|
||||
const hash = hmac.digest("hex");
|
||||
const ok = crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(v1));
|
||||
return ok ? { ok: true } : { ok: false, reason: "invalid_signature" };
|
||||
}
|
||||
|
||||
export async function fetchPayment({ paymentId }) {
|
||||
if (!paymentId) throw new Error("missing_payment_id");
|
||||
return await fetchMp({
|
||||
url: `https://api.mercadopago.com/v1/payments/${encodeURIComponent(paymentId)}`,
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
export function parseExternalReference(externalReference) {
|
||||
if (!externalReference) return { tenantId: null, wooOrderId: null };
|
||||
const parts = String(externalReference).split("|").filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
return { tenantId: parts[0], wooOrderId: Number(parts[1]) || null };
|
||||
}
|
||||
return { tenantId: null, wooOrderId: Number(externalReference) || null };
|
||||
}
|
||||
|
||||
export async function reconcilePayment({ tenantId, payment }) {
|
||||
const status = payment?.status || null;
|
||||
const paidAt = payment?.date_approved || payment?.date_created || null;
|
||||
const { tenantId: refTenantId, wooOrderId } = parseExternalReference(payment?.external_reference);
|
||||
const resolvedTenantId = tenantId || refTenantId;
|
||||
if (!resolvedTenantId) throw new Error("tenant_id_missing_from_payment");
|
||||
const saved = await upsertMpPayment({
|
||||
tenant_id: resolvedTenantId,
|
||||
woo_order_id: wooOrderId,
|
||||
preference_id: payment?.order?.id || payment?.preference_id || null,
|
||||
payment_id: String(payment?.id || ""),
|
||||
status,
|
||||
paid_at: paidAt,
|
||||
raw: payment,
|
||||
});
|
||||
|
||||
if (status === "approved" && wooOrderId) {
|
||||
await updateOrderStatus({ tenantId: resolvedTenantId, wooOrderId, status: "processing" });
|
||||
}
|
||||
|
||||
return { payment: saved, woo_order_id: wooOrderId, tenant_id: resolvedTenantId };
|
||||
}
|
||||
|
||||
10
src/modules/6-mercadopago/routes/mercadoPago.js
Normal file
10
src/modules/6-mercadopago/routes/mercadoPago.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import express from "express";
|
||||
import { makeMercadoPagoReturn, makeMercadoPagoWebhook } from "../controllers/mercadoPago.js";
|
||||
|
||||
export function createMercadoPagoRouter() {
|
||||
const router = express.Router();
|
||||
router.post("/webhook/mercadopago", makeMercadoPagoWebhook());
|
||||
router.get("/return", makeMercadoPagoReturn());
|
||||
return router;
|
||||
}
|
||||
|
||||
37
src/modules/shared/debug.js
Normal file
37
src/modules/shared/debug.js
Normal file
@@ -0,0 +1,37 @@
|
||||
function envIsOn(v) {
|
||||
const s = String(v || "").trim().toLowerCase();
|
||||
return s === "1" || s === "true" || s === "yes" || s === "on";
|
||||
}
|
||||
|
||||
function envIsOff(v) {
|
||||
const s = String(v || "").trim().toLowerCase();
|
||||
return s === "0" || s === "false" || s === "no" || s === "off";
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug flags (por temas)
|
||||
*
|
||||
* - DEBUG_PERF: performance/latencias
|
||||
* - DEBUG_WOO_HTTP: requests/responses a Woo (status, timings, tamaño)
|
||||
* - DEBUG_LLM: requests/responses a OpenAI
|
||||
* - DEBUG_EVOLUTION: hook evolution + parse
|
||||
* - DEBUG_DB: queries/latencias DB (si se instrumenta)
|
||||
* - DEBUG_RESOLVE: debug de resolución/ambiguity (pipeline)
|
||||
*/
|
||||
export const debug = {
|
||||
perf: envIsOn(process.env.DEBUG_PERF),
|
||||
|
||||
wooHttp: envIsOn(process.env.DEBUG_WOO_HTTP),
|
||||
llm: envIsOn(process.env.DEBUG_LLM),
|
||||
|
||||
evolution: envIsOn(process.env.DEBUG_EVOLUTION),
|
||||
|
||||
db: envIsOn(process.env.DEBUG_DB),
|
||||
|
||||
resolve: envIsOn(process.env.DEBUG_RESOLVE),
|
||||
};
|
||||
|
||||
export function debugOn(flagName) {
|
||||
return Boolean(debug?.[flagName]);
|
||||
}
|
||||
|
||||
15
src/modules/shared/sse.js
Normal file
15
src/modules/shared/sse.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const sseClients = new Set();
|
||||
|
||||
export function addSseClient(res) {
|
||||
sseClients.add(res);
|
||||
}
|
||||
|
||||
export function removeSseClient(res) {
|
||||
sseClients.delete(res);
|
||||
}
|
||||
|
||||
export function sseSend(event, data) {
|
||||
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
for (const res of sseClients) res.write(payload);
|
||||
}
|
||||
|
||||
253
src/modules/shared/wooSnapshot.js
Normal file
253
src/modules/shared/wooSnapshot.js
Normal file
@@ -0,0 +1,253 @@
|
||||
import { pool } from "../2-identity/db/pool.js";
|
||||
import { getDecryptedTenantEcommerceConfig } from "../2-identity/db/repo.js";
|
||||
import { debug as dbg } from "./debug.js";
|
||||
|
||||
async function fetchWoo({ url, method = "GET", body = null, timeout = 20000, headers = {} }) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(new Error("timeout")), timeout);
|
||||
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;
|
||||
} 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 ?? 20000, 20000),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePrice(p) {
|
||||
if (p == null) return null;
|
||||
const n = Number(String(p).replace(",", "."));
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function normalizeAttributes(attrs) {
|
||||
const out = {};
|
||||
if (!Array.isArray(attrs)) return out;
|
||||
for (const a of attrs) {
|
||||
const name = String(a?.name || "").trim().toLowerCase();
|
||||
if (!name) continue;
|
||||
const options = Array.isArray(a?.options) ? a.options.map((v) => String(v).trim()).filter(Boolean) : [];
|
||||
const value = a?.option ? [String(a.option).trim()] : options;
|
||||
if (value.length) out[name] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeWooProduct(p) {
|
||||
return {
|
||||
woo_id: p?.id,
|
||||
type: p?.type || "simple",
|
||||
parent_id: p?.parent_id || null,
|
||||
name: p?.name || "",
|
||||
slug: p?.slug || null,
|
||||
status: p?.status || null,
|
||||
catalog_visibility: p?.catalog_visibility || null,
|
||||
price_regular: parsePrice(p?.regular_price),
|
||||
price_sale: parsePrice(p?.sale_price),
|
||||
price_current: parsePrice(p?.price),
|
||||
stock_status: p?.stock_status || null,
|
||||
stock_qty: p?.stock_quantity != null ? Number(p.stock_quantity) : null,
|
||||
backorders: p?.backorders || null,
|
||||
categories: Array.isArray(p?.categories) ? p.categories.map((c) => c?.name || c?.slug).filter(Boolean) : [],
|
||||
tags: Array.isArray(p?.tags) ? p.tags.map((c) => c?.name || c?.slug).filter(Boolean) : [],
|
||||
attributes_normalized: normalizeAttributes(p?.attributes || []),
|
||||
date_modified: p?.date_modified || null,
|
||||
raw: p,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotRowToItem(row) {
|
||||
const categories = Array.isArray(row?.categories) ? row.categories : [];
|
||||
const attributes = row?.attributes_normalized && typeof row.attributes_normalized === "object" ? row.attributes_normalized : {};
|
||||
return {
|
||||
woo_product_id: row?.woo_id,
|
||||
name: row?.name || "",
|
||||
sku: row?.slug || null,
|
||||
price: row?.price_current != null ? Number(row.price_current) : null,
|
||||
currency: null,
|
||||
type: row?.type || null,
|
||||
categories: categories.map((c) => ({ id: null, name: String(c), slug: String(c) })),
|
||||
attributes: Object.entries(attributes).map(([name, options]) => ({
|
||||
name,
|
||||
options: Array.isArray(options) ? options : [String(options)],
|
||||
})),
|
||||
raw_price: {
|
||||
price: row?.price_current ?? null,
|
||||
regular_price: row?.price_regular ?? null,
|
||||
sale_price: row?.price_sale ?? null,
|
||||
price_html: null,
|
||||
},
|
||||
source: "snapshot",
|
||||
};
|
||||
}
|
||||
|
||||
export async function insertSnapshotRun({ tenantId, source, total }) {
|
||||
const { rows } = await pool.query(
|
||||
`insert into woo_snapshot_runs (tenant_id, source, total_items) values ($1, $2, $3) returning id`,
|
||||
[tenantId, source, total || 0]
|
||||
);
|
||||
return rows[0]?.id || null;
|
||||
}
|
||||
|
||||
export async function searchSnapshotItems({ tenantId, q = "", limit = 12 }) {
|
||||
const lim = Math.max(1, Math.min(50, parseInt(limit, 10) || 12));
|
||||
const query = String(q || "").trim();
|
||||
if (!query) return { items: [], source: "snapshot" };
|
||||
const like = `%${query}%`;
|
||||
const sql = `
|
||||
select *
|
||||
from sellable_items
|
||||
where tenant_id=$1
|
||||
and (name ilike $2 or coalesce(slug,'') ilike $2)
|
||||
order by updated_at desc
|
||||
limit $3
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenantId, like, lim]);
|
||||
return { items: rows.map(snapshotRowToItem), source: "snapshot" };
|
||||
}
|
||||
|
||||
export async function getSnapshotPriceByWooId({ tenantId, wooId }) {
|
||||
if (!wooId) return null;
|
||||
const sql = `
|
||||
select price_current
|
||||
from woo_products_snapshot
|
||||
where tenant_id=$1 and woo_id=$2
|
||||
limit 1
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenantId, wooId]);
|
||||
const price = rows[0]?.price_current;
|
||||
return price == null ? null : Number(price);
|
||||
}
|
||||
|
||||
export async function upsertSnapshotItems({ tenantId, items, runId = null }) {
|
||||
const rows = Array.isArray(items) ? items : [];
|
||||
for (const item of rows) {
|
||||
const q = `
|
||||
insert into woo_products_snapshot
|
||||
(tenant_id, woo_id, type, parent_id, name, slug, status, catalog_visibility,
|
||||
price_regular, price_sale, price_current, stock_status, stock_qty, backorders,
|
||||
categories, tags, attributes_normalized, date_modified, run_id, raw, updated_at)
|
||||
values
|
||||
($1,$2,$3,$4,$5,$6,$7,$8,
|
||||
$9,$10,$11,$12,$13,$14,
|
||||
$15::jsonb,$16::jsonb,$17::jsonb,$18,$19,$20::jsonb,now())
|
||||
on conflict (tenant_id, woo_id)
|
||||
do update set
|
||||
type = excluded.type,
|
||||
parent_id = excluded.parent_id,
|
||||
name = excluded.name,
|
||||
slug = excluded.slug,
|
||||
status = excluded.status,
|
||||
catalog_visibility = excluded.catalog_visibility,
|
||||
price_regular = excluded.price_regular,
|
||||
price_sale = excluded.price_sale,
|
||||
price_current = excluded.price_current,
|
||||
stock_status = excluded.stock_status,
|
||||
stock_qty = excluded.stock_qty,
|
||||
backorders = excluded.backorders,
|
||||
categories = excluded.categories,
|
||||
tags = excluded.tags,
|
||||
attributes_normalized = excluded.attributes_normalized,
|
||||
date_modified = excluded.date_modified,
|
||||
run_id = excluded.run_id,
|
||||
raw = excluded.raw,
|
||||
updated_at = now()
|
||||
`;
|
||||
await pool.query(q, [
|
||||
tenantId,
|
||||
item.woo_id,
|
||||
item.type,
|
||||
item.parent_id,
|
||||
item.name,
|
||||
item.slug,
|
||||
item.status,
|
||||
item.catalog_visibility,
|
||||
item.price_regular,
|
||||
item.price_sale,
|
||||
item.price_current,
|
||||
item.stock_status,
|
||||
item.stock_qty,
|
||||
item.backorders,
|
||||
JSON.stringify(item.categories || []),
|
||||
JSON.stringify(item.tags || []),
|
||||
JSON.stringify(item.attributes_normalized || {}),
|
||||
item.date_modified,
|
||||
runId,
|
||||
JSON.stringify(item.raw || {}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMissingItems({ tenantId, runId }) {
|
||||
if (!runId) return;
|
||||
await pool.query(
|
||||
`delete from woo_products_snapshot where tenant_id=$1 and coalesce(run_id,0) <> $2`,
|
||||
[tenantId, runId]
|
||||
);
|
||||
}
|
||||
|
||||
export async function refreshProductByWooId({ tenantId, wooId, parentId = null }) {
|
||||
const client = await getWooClient({ tenantId });
|
||||
let url = `${client.base}/products/${encodeURIComponent(wooId)}`;
|
||||
if (parentId) {
|
||||
url = `${client.base}/products/${encodeURIComponent(parentId)}/variations/${encodeURIComponent(wooId)}`;
|
||||
}
|
||||
const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
|
||||
if (dbg.wooHttp) console.log("[wooSnapshot] refresh", { wooId, parentId, type: data?.type });
|
||||
const normalized = normalizeWooProduct(data);
|
||||
await upsertSnapshotItems({ tenantId, items: [normalized], runId: null });
|
||||
return normalized;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user