woocommerce integration, controllers and handlers ready, evolution api simulator ready

This commit is contained in:
Lucas Tettamanti
2026-01-02 16:49:35 -03:00
parent 556c49e53d
commit 303c3daafe
19 changed files with 637 additions and 141 deletions

View File

@@ -0,0 +1,16 @@
import { handleGetConversationState } from "../handlers/conversationState.js";
export const makeGetConversationState = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const { status, payload } = await handleGetConversationState({
tenantId,
chat_id: req.query.chat_id || null,
});
res.status(status).json(payload);
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};

View File

@@ -0,0 +1,13 @@
import { handleListConversations } from "../handlers/conversations.js";
export const makeGetConversations = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const items = await handleListConversations({ tenantId, query: req.query });
res.json({ items });
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};

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

29
src/controllers/runs.js Normal file
View File

@@ -0,0 +1,29 @@
import { handleListRuns, handleGetRun } from "../handlers/runs.js";
export const makeListRuns = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const items = await handleListRuns({
tenantId,
chat_id: req.query.chat_id || null,
limit: req.query.limit || "50",
});
res.json({ items });
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};
export const makeGetRunById = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const run = await handleGetRun({ tenantId, run_id: req.params.run_id });
if (!run) return res.status(404).json({ ok: false, error: "not_found" });
res.json(run);
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};

12
src/controllers/sim.js Normal file
View 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) });
}
};

View File

@@ -242,25 +242,6 @@ export async function getRunById({ tenant_id, run_id }) {
};
}
async function getRecentMessages({ tenant_id, wa_chat_id, limit = 20 }) {
const lim = Math.max(1, Math.min(50, parseInt(limit, 10) || 20)); // hard cap 50
const q = `
select direction, ts, text
from wa_messages
where tenant_id=$1 and wa_chat_id=$2
order by ts desc
limit $3
`;
const { rows } = await pool.query(q, [tenant_id, wa_chat_id, lim]);
// rows vienen newest-first, lo devolvemos oldest-first
return rows.reverse().map(r => ({
role: r.direction === "in" ? "user" : "assistant",
content: r.text || "",
ts: r.ts,
}));
}
export async function getRecentMessagesForLLM({
tenant_id,
wa_chat_id,
@@ -285,3 +266,77 @@ export async function getRecentMessagesForLLM({
content: String(r.text).trim().slice(0, maxCharsPerMessage),
}));
}
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, provider)
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;
}

View File

@@ -0,0 +1,21 @@
import { getConversationState } from "../db/repo.js";
export async function handleGetConversationState({ tenantId, chat_id }) {
if (!chat_id) {
return { status: 400, payload: { ok: false, error: "chat_id required" } };
}
const row = await getConversationState(tenantId, chat_id);
if (!row) return { status: 404, payload: { ok: false, error: "not_found" } };
return {
status: 200,
payload: {
ok: true,
state: row.state,
last_intent: row.last_intent,
last_order_id: row.last_order_id,
context: row.context,
state_updated_at: row.state_updated_at,
},
};
}

View File

@@ -0,0 +1,13 @@
import { listConversations } from "../db/repo.js";
export async function handleListConversations({ tenantId, query }) {
const { q = "", status = "", state = "", limit = "50" } = query || {};
return listConversations({
tenant_id: tenantId,
q: String(q || ""),
status: String(status || ""),
state: String(state || ""),
limit: parseInt(limit, 10) || 50,
});
}

29
src/handlers/evolution.js Normal file
View File

@@ -0,0 +1,29 @@
import crypto from "crypto";
import { parseEvolutionWebhook } from "../services/evolutionParser.js";
import { resolveTenantId, processMessage } from "../services/pipeline.js";
export async function handleEvolutionWebhook(body) {
const parsed = parseEvolutionWebhook(body);
if (!parsed.ok) {
return { status: 200, payload: { ok: true, ignored: parsed.reason } };
}
const tenantId = await resolveTenantId({
chat_id: parsed.chat_id,
tenant_key: parsed.tenant_key,
to_phone: null,
});
await processMessage({
tenantId,
chat_id: parsed.chat_id,
from: parsed.chat_id.replace("@s.whatsapp.net", ""),
text: parsed.text,
provider: "evolution",
message_id: parsed.message_id || crypto.randomUUID(),
meta: { pushName: parsed.from_name, ts: parsed.ts, instance: parsed.tenant_key },
});
return { status: 200, payload: { ok: true } };
}

14
src/handlers/runs.js Normal file
View File

@@ -0,0 +1,14 @@
import { listRuns, getRunById } from "../db/repo.js";
export async function handleListRuns({ tenantId, chat_id = null, limit = "50" }) {
return listRuns({
tenant_id: tenantId,
wa_chat_id: chat_id ? String(chat_id) : null,
limit: parseInt(limit, 10) || 50,
});
}
export async function handleGetRun({ tenantId, run_id }) {
return getRunById({ tenant_id: tenantId, run_id });
}

30
src/handlers/sim.js Normal file
View File

@@ -0,0 +1,30 @@
import crypto from "crypto";
import { resolveTenantId } from "../services/pipeline.js";
import { processMessage } from "../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 } };
}

View File

@@ -0,0 +1,56 @@
export function parseEvolutionWebhook(reqBody) {
// n8n a veces entrega array con { body }, en express real suele ser directo
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;
return {
ok: true,
tenant_key: instance || null,
chat_id: remoteJid,
message_id: messageId || null,
text: cleanText,
from_name: pushName,
message_type: messageType || null,
ts,
raw: body, // para log/debug si querés
};
}

View File

@@ -5,8 +5,11 @@ import {
insertRun,
upsertConversationState,
getRecentMessagesForLLM,
getExternalCustomerIdByChat,
upsertExternalCustomerMap,
} from "../db/repo.js";
import { sseSend } from "./sse.js";
import { createWooCustomer, getWooCustomerById } from "./woo.js";
function nowIso() {
@@ -20,7 +23,15 @@ function newId(prefix = "run") {
export async function processMessage({ tenantId, chat_id, from, text, provider, message_id }) {
const started_at = Date.now();
const prev = await getConversationState(tenantId, chat_id);
const prev_state = prev?.state || "IDLE";
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",
});
await insertMessage({
tenant_id: tenantId,
@@ -79,9 +90,37 @@ export async function processMessage({ tenantId, chat_id, from, text, provider,
run_id,
});
// Si no tenemos cliente Woo mapeado, creamos uno (stub) y guardamos el mapping.
if (externalCustomerId) {
// validar existencia en Woo; si no existe, lo recreamos
const found = await getWooCustomerById({ tenantId, id: externalCustomerId });
if (!found) {
const phone = chat_id.replace(/@.+$/, "");
const name = from || phone;
const created = await createWooCustomer({ tenantId, wa_chat_id: chat_id, phone, name });
externalCustomerId = await upsertExternalCustomerMap({
tenant_id: tenantId,
wa_chat_id: chat_id,
external_customer_id: created?.id,
provider: "woo",
});
}
} else {
const phone = chat_id.replace(/@.+$/, "");
const name = from || phone;
const created = await createWooCustomer({ tenantId, wa_chat_id: chat_id, phone, name });
externalCustomerId = await upsertExternalCustomerMap({
tenant_id: tenantId,
wa_chat_id: chat_id,
external_customer_id: created?.id,
provider: "woo",
});
}
const context = {
missing_fields: plan.missing_fields || [],
basket_resolved: plan.basket_resolved || { items: [] },
external_customer_id: externalCustomerId ?? prev?.context?.external_customer_id ?? null,
};
const stateRow = await upsertConversationState({
@@ -121,17 +160,21 @@ export async function processMessage({ tenantId, chat_id, from, text, provider,
});
const history = await getRecentMessagesForLLM({
tenant_id: TENANT_ID,
tenant_id: tenantId,
wa_chat_id: chat_id,
limit: 20,
});
const compactHistory = collapseAssistantMessages(history);
const llmInput = {
wa_chat_id: chat_id,
last_user_message: text,
conversation_history: history,
conversation_history: compactHistory,
current_conversation_state: prev_state,
context: prev?.context || {},
context: {
...(prev?.context || {}),
external_customer_id: externalCustomerId ?? prev?.context?.external_customer_id ?? null,
},
};
return { run_id, reply: plan.reply };
@@ -147,3 +190,34 @@ export function collapseAssistantMessages(messages) {
return out;
}
import { ensureTenant, getTenantByKey, getTenantIdByChannel } from "../db/repo.js";
function parseTenantFromChatId(chat_id) {
// soporta "piaf:sim:+54..." o "piaf:+54..." etc.
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 }) {
// Normalizar key a lowercase para evitar duplicados por casing
const explicit = (tenant_key || parseTenantFromChatId(chat_id) || "").toLowerCase();
// 1) si viene explícito (simulador / webhook)
if (explicit) {
const t = await getTenantByKey(explicit);
if (t) return t.id;
throw new Error(`tenant_not_found: ${explicit}`);
}
// 2) si viene el número receptor / channel key (producción)
if (to_phone) {
const id = await getTenantIdByChannel({ channel_type: "whatsapp", channel_key: to_phone });
if (id) return id;
}
// 3) fallback: env TENANT_KEY
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}`);
}

114
src/services/woo.js Normal file
View File

@@ -0,0 +1,114 @@
import crypto from "crypto";
import { getDecryptedTenantEcommerceConfig } from "../db/repo.js";
async function fetchWoo({ url, method = "GET", body = null, timeout = 8000 }) {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), timeout);
try {
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : null,
signal: controller.signal,
});
const text = await res.text();
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
// ignore parse error
}
if (!res.ok) {
const err = new Error(`Woo request failed: ${res.status}`);
err.status = res.status;
err.body = json || text;
throw err;
}
return json;
} finally {
clearTimeout(t);
}
}
export async function createWooCustomer({ tenantId, wa_chat_id, phone, name }) {
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?consumer_key=${encodeURIComponent(
consumerKey
)}&consumer_secret=${encodeURIComponent(consumerSecret)}`;
const payload = {
email: `${phone || wa_chat_id}@no-email.local`,
first_name: name || phone || wa_chat_id,
username: phone || wa_chat_id,
password: crypto.randomBytes(12).toString("base64url"), // requerido por Woo
billing: {
phone: phone || wa_chat_id,
},
};
const data = await fetchWoo({ url, method: "POST", body: payload, timeout: cfg.timeout_ms });
return { id: data?.id, raw: data };
}
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/${id}?consumer_key=${encodeURIComponent(
consumerKey
)}&consumer_secret=${encodeURIComponent(consumerSecret)}`;
try {
const data = await fetchWoo({ url, method: "GET", timeout: cfg.timeout_ms });
return data;
} catch (err) {
if (err.status === 404) return null;
throw err;
}
}