resuelto el problema, el post para crear customer creaba pero no emitia respuesta, se deja en 2 segundos y se usa un get por email para traer la data
This commit is contained in:
20
index.js
20
index.js
@@ -11,6 +11,25 @@ import { makeSimSend } from "./src/controllers/sim.js";
|
|||||||
import { makeEvolutionWebhook } from "./src/controllers/evolution.js";
|
import { makeEvolutionWebhook } from "./src/controllers/evolution.js";
|
||||||
import { makeGetConversationState } from "./src/controllers/conversationState.js";
|
import { makeGetConversationState } from "./src/controllers/conversationState.js";
|
||||||
|
|
||||||
|
async function configureUndiciDispatcher() {
|
||||||
|
// Node 18+ usa undici debajo de fetch. Esto suele arreglar timeouts “fantasma” por keep-alive/pooling.
|
||||||
|
// Nota: si el módulo `undici` no está disponible, no rompemos el arranque (solo logueamos warning).
|
||||||
|
try {
|
||||||
|
const { setGlobalDispatcher, Agent } = await import("undici");
|
||||||
|
setGlobalDispatcher(
|
||||||
|
new Agent({
|
||||||
|
connections: 10,
|
||||||
|
pipelining: 0,
|
||||||
|
keepAliveTimeout: 10_000,
|
||||||
|
keepAliveMaxTimeout: 10_000,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
console.log("[http] undici global dispatcher configured");
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[http] undici dispatcher not configured:", e?.message || e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json({ limit: "1mb" }));
|
app.use(express.json({ limit: "1mb" }));
|
||||||
@@ -73,6 +92,7 @@ app.get("/", (req, res) => {
|
|||||||
const port = process.env.PORT || 3000;
|
const port = process.env.PORT || 3000;
|
||||||
|
|
||||||
(async function boot() {
|
(async function boot() {
|
||||||
|
await configureUndiciDispatcher();
|
||||||
TENANT_ID = await ensureTenant({ key: TENANT_KEY, name: TENANT_KEY.toUpperCase() });
|
TENANT_ID = await ensureTenant({ key: TENANT_KEY, name: TENANT_KEY.toUpperCase() });
|
||||||
app.listen(port, () => console.log(`UI: http://localhost:${port} (tenant=${TENANT_KEY})`));
|
app.listen(port, () => console.log(`UI: http://localhost:${port} (tenant=${TENANT_KEY})`));
|
||||||
})().catch((err) => {
|
})().catch((err) => {
|
||||||
|
|||||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -14,6 +14,7 @@
|
|||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"openai": "^6.15.0",
|
"openai": "^6.15.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
|
"undici": "^7.16.0",
|
||||||
"zod": "^4.3.4"
|
"zod": "^4.3.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1488,6 +1489,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/undici": {
|
||||||
|
"version": "7.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz",
|
||||||
|
"integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"openai": "^6.15.0",
|
"openai": "^6.15.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
|
"undici": "^7.16.0",
|
||||||
"zod": "^4.3.4"
|
"zod": "^4.3.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -135,6 +135,17 @@ export async function insertRun({
|
|||||||
return rows[0]?.id || null;
|
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 }) {
|
export async function listConversations({ tenant_id, q = "", status = "", state = "", limit = 50 }) {
|
||||||
const params = [tenant_id];
|
const params = [tenant_id];
|
||||||
let where = `where tenant_id=$1`;
|
let where = `where tenant_id=$1`;
|
||||||
@@ -301,7 +312,7 @@ export async function upsertExternalCustomerMap({
|
|||||||
const q = `
|
const q = `
|
||||||
insert into wa_identity_map (tenant_id, wa_chat_id, provider, external_customer_id, created_at, updated_at)
|
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())
|
values ($1, $2, $3, $4, now(), now())
|
||||||
on conflict (tenant_id, wa_chat_id, provider)
|
on conflict (tenant_id, wa_chat_id)
|
||||||
do update set external_customer_id = excluded.external_customer_id, updated_at = now()
|
do update set external_customer_id = excluded.external_customer_id, updated_at = now()
|
||||||
returning external_customer_id
|
returning external_customer_id
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -3,27 +3,44 @@ import { parseEvolutionWebhook } from "../services/evolutionParser.js";
|
|||||||
import { resolveTenantId, processMessage } from "../services/pipeline.js";
|
import { resolveTenantId, processMessage } from "../services/pipeline.js";
|
||||||
|
|
||||||
export async function handleEvolutionWebhook(body) {
|
export async function handleEvolutionWebhook(body) {
|
||||||
|
const t0 = Date.now();
|
||||||
const parsed = parseEvolutionWebhook(body);
|
const parsed = parseEvolutionWebhook(body);
|
||||||
if (!parsed.ok) {
|
if (!parsed.ok) {
|
||||||
return { status: 200, payload: { ok: true, ignored: parsed.reason } };
|
return { status: 200, payload: { ok: true, ignored: parsed.reason } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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({
|
const tenantId = await resolveTenantId({
|
||||||
chat_id: parsed.chat_id,
|
chat_id: parsed.chat_id,
|
||||||
tenant_key: parsed.tenant_key,
|
tenant_key: parsed.tenant_key,
|
||||||
to_phone: null,
|
to_phone: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
await processMessage({
|
const pm = await processMessage({
|
||||||
tenantId,
|
tenantId,
|
||||||
chat_id: parsed.chat_id,
|
chat_id: parsed.chat_id,
|
||||||
from: parsed.chat_id.replace("@s.whatsapp.net", ""),
|
from: parsed.chat_id.replace("@s.whatsapp.net", ""),
|
||||||
|
displayName: parsed.from_name || null,
|
||||||
text: parsed.text,
|
text: parsed.text,
|
||||||
provider: "evolution",
|
provider: "evolution",
|
||||||
message_id: parsed.message_id || crypto.randomUUID(),
|
message_id: parsed.message_id || crypto.randomUUID(),
|
||||||
meta: { pushName: parsed.from_name, ts: parsed.ts, instance: parsed.tenant_key },
|
meta: { pushName: parsed.from_name, ts: parsed.ts, instance: parsed.tenant_key },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 } };
|
return { status: 200, payload: { ok: true } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export function parseEvolutionWebhook(reqBody) {
|
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 envelope = Array.isArray(reqBody) ? reqBody[0] : reqBody;
|
||||||
const body = envelope?.body ?? envelope ?? {};
|
const body = envelope?.body ?? envelope ?? {};
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getRecentMessagesForLLM,
|
getRecentMessagesForLLM,
|
||||||
getExternalCustomerIdByChat,
|
getExternalCustomerIdByChat,
|
||||||
upsertExternalCustomerMap,
|
upsertExternalCustomerMap,
|
||||||
|
updateRunLatency,
|
||||||
} from "../db/repo.js";
|
} from "../db/repo.js";
|
||||||
import { sseSend } from "./sse.js";
|
import { sseSend } from "./sse.js";
|
||||||
import { createWooCustomer, getWooCustomerById } from "./woo.js";
|
import { createWooCustomer, getWooCustomerById } from "./woo.js";
|
||||||
@@ -20,9 +21,30 @@ function newId(prefix = "run") {
|
|||||||
return `${prefix}_${crypto.randomUUID()}`;
|
return `${prefix}_${crypto.randomUUID()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function processMessage({ tenantId, chat_id, from, text, provider, message_id }) {
|
export async function processMessage({
|
||||||
|
tenantId,
|
||||||
|
chat_id,
|
||||||
|
from,
|
||||||
|
text,
|
||||||
|
provider,
|
||||||
|
message_id,
|
||||||
|
displayName = null,
|
||||||
|
}) {
|
||||||
const started_at = Date.now();
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
mark("start");
|
||||||
const prev = await getConversationState(tenantId, chat_id);
|
const prev = await getConversationState(tenantId, chat_id);
|
||||||
|
mark("after_getConversationState");
|
||||||
const isStale =
|
const isStale =
|
||||||
prev?.state_updated_at &&
|
prev?.state_updated_at &&
|
||||||
Date.now() - new Date(prev.state_updated_at).getTime() > 24 * 60 * 60 * 1000;
|
Date.now() - new Date(prev.state_updated_at).getTime() > 24 * 60 * 60 * 1000;
|
||||||
@@ -32,6 +54,7 @@ export async function processMessage({ tenantId, chat_id, from, text, provider,
|
|||||||
wa_chat_id: chat_id,
|
wa_chat_id: chat_id,
|
||||||
provider: "woo",
|
provider: "woo",
|
||||||
});
|
});
|
||||||
|
mark("after_getExternalCustomerIdByChat");
|
||||||
|
|
||||||
await insertMessage({
|
await insertMessage({
|
||||||
tenant_id: tenantId,
|
tenant_id: tenantId,
|
||||||
@@ -43,6 +66,7 @@ export async function processMessage({ tenantId, chat_id, from, text, provider,
|
|||||||
payload: { raw: { from, text } },
|
payload: { raw: { from, text } },
|
||||||
run_id: null,
|
run_id: null,
|
||||||
});
|
});
|
||||||
|
mark("after_insertMessage_in");
|
||||||
|
|
||||||
const plan = {
|
const plan = {
|
||||||
reply: `Recibido: "${text}". ¿Querés retiro o envío?`,
|
reply: `Recibido: "${text}". ¿Querés retiro o envío?`,
|
||||||
@@ -61,8 +85,7 @@ export async function processMessage({ tenantId, chat_id, from, text, provider,
|
|||||||
{ name: "no_order_action_without_items", ok: true },
|
{ name: "no_order_action_without_items", ok: true },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
mark("before_insertRun");
|
||||||
const latency_ms = Date.now() - started_at;
|
|
||||||
|
|
||||||
const run_id = await insertRun({
|
const run_id = await insertRun({
|
||||||
tenant_id: tenantId,
|
tenant_id: tenantId,
|
||||||
@@ -75,8 +98,9 @@ export async function processMessage({ tenantId, chat_id, from, text, provider,
|
|||||||
invariants,
|
invariants,
|
||||||
final_reply: plan.reply,
|
final_reply: plan.reply,
|
||||||
status: "ok",
|
status: "ok",
|
||||||
latency_ms,
|
latency_ms: null, // se actualiza al final con end-to-end real
|
||||||
});
|
});
|
||||||
|
mark("after_insertRun");
|
||||||
|
|
||||||
const outMessageId = newId("out");
|
const outMessageId = newId("out");
|
||||||
await insertMessage({
|
await insertMessage({
|
||||||
@@ -89,26 +113,38 @@ export async function processMessage({ tenantId, chat_id, from, text, provider,
|
|||||||
payload: { reply: plan.reply },
|
payload: { reply: plan.reply },
|
||||||
run_id,
|
run_id,
|
||||||
});
|
});
|
||||||
|
mark("after_insertMessage_out");
|
||||||
|
|
||||||
// Si no tenemos cliente Woo mapeado, creamos uno (stub) y guardamos el mapping.
|
mark("before_ensureWooCustomer");
|
||||||
if (externalCustomerId) {
|
if (externalCustomerId) {
|
||||||
// validar existencia en Woo; si no existe, lo recreamos
|
|
||||||
const found = await getWooCustomerById({ tenantId, id: externalCustomerId });
|
const found = await getWooCustomerById({ tenantId, id: externalCustomerId });
|
||||||
if (!found) {
|
if (!found) {
|
||||||
const phone = chat_id.replace(/@.+$/, "");
|
const phone = chat_id.replace(/@.+$/, "");
|
||||||
const name = from || phone;
|
const name = displayName || from || phone;
|
||||||
const created = await createWooCustomer({ tenantId, wa_chat_id: chat_id, phone, name });
|
const created = await createWooCustomer({ tenantId, wa_chat_id: chat_id, phone, name });
|
||||||
|
console.log("created", created);
|
||||||
|
if (!created?.id) throw new Error("woo_customer_id_missing");
|
||||||
externalCustomerId = await upsertExternalCustomerMap({
|
externalCustomerId = await upsertExternalCustomerMap({
|
||||||
tenant_id: tenantId,
|
tenant_id: tenantId,
|
||||||
wa_chat_id: chat_id,
|
wa_chat_id: chat_id,
|
||||||
external_customer_id: created?.id,
|
external_customer_id: created?.id,
|
||||||
provider: "woo",
|
provider: "woo",
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Asegurar que el mapping exista/esté actualizado
|
||||||
|
externalCustomerId = await upsertExternalCustomerMap({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
wa_chat_id: chat_id,
|
||||||
|
external_customer_id: externalCustomerId,
|
||||||
|
provider: "woo",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const phone = chat_id.replace(/@.+$/, "");
|
const phone = chat_id.replace(/@.+$/, "");
|
||||||
const name = from || phone;
|
const name = displayName || from || phone;
|
||||||
const created = await createWooCustomer({ tenantId, wa_chat_id: chat_id, phone, name });
|
const created = await createWooCustomer({ tenantId, wa_chat_id: chat_id, phone, name });
|
||||||
|
console.log("created", created);
|
||||||
|
if (!created?.id) throw new Error("woo_customer_id_missing");
|
||||||
externalCustomerId = await upsertExternalCustomerMap({
|
externalCustomerId = await upsertExternalCustomerMap({
|
||||||
tenant_id: tenantId,
|
tenant_id: tenantId,
|
||||||
wa_chat_id: chat_id,
|
wa_chat_id: chat_id,
|
||||||
@@ -116,6 +152,7 @@ export async function processMessage({ tenantId, chat_id, from, text, provider,
|
|||||||
provider: "woo",
|
provider: "woo",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
mark("after_ensureWooCustomer");
|
||||||
|
|
||||||
const context = {
|
const context = {
|
||||||
missing_fields: plan.missing_fields || [],
|
missing_fields: plan.missing_fields || [],
|
||||||
@@ -131,6 +168,7 @@ export async function processMessage({ tenantId, chat_id, from, text, provider,
|
|||||||
last_order_id: null,
|
last_order_id: null,
|
||||||
context,
|
context,
|
||||||
});
|
});
|
||||||
|
mark("after_upsertConversationState");
|
||||||
|
|
||||||
sseSend("conversation.upsert", {
|
sseSend("conversation.upsert", {
|
||||||
chat_id: stateRow.wa_chat_id,
|
chat_id: stateRow.wa_chat_id,
|
||||||
@@ -142,6 +180,31 @@ export async function processMessage({ tenantId, chat_id, from, text, provider,
|
|||||||
last_run_id: run_id,
|
last_run_id: run_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const history = await getRecentMessagesForLLM({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
wa_chat_id: chat_id,
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
const compactHistory = collapseAssistantMessages(history);
|
||||||
|
mark("after_getRecentMessagesForLLM");
|
||||||
|
|
||||||
|
const llmInput = {
|
||||||
|
wa_chat_id: chat_id,
|
||||||
|
last_user_message: text,
|
||||||
|
conversation_history: compactHistory,
|
||||||
|
current_conversation_state: prev_state,
|
||||||
|
context: {
|
||||||
|
...(prev?.context || {}),
|
||||||
|
external_customer_id: externalCustomerId ?? prev?.context?.external_customer_id ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
void llmInput;
|
||||||
|
|
||||||
|
const end_to_end_ms = Date.now() - started_at;
|
||||||
|
if (run_id) {
|
||||||
|
await updateRunLatency({ tenant_id: tenantId, run_id, latency_ms: end_to_end_ms });
|
||||||
|
}
|
||||||
|
|
||||||
sseSend("run.created", {
|
sseSend("run.created", {
|
||||||
run_id,
|
run_id,
|
||||||
ts: nowIso(),
|
ts: nowIso(),
|
||||||
@@ -156,26 +219,28 @@ export async function processMessage({ tenantId, chat_id, from, text, provider,
|
|||||||
final_reply: plan.reply,
|
final_reply: plan.reply,
|
||||||
order_id: null,
|
order_id: null,
|
||||||
payment_link: null,
|
payment_link: null,
|
||||||
latency_ms,
|
latency_ms: end_to_end_ms,
|
||||||
});
|
});
|
||||||
|
|
||||||
const history = await getRecentMessagesForLLM({
|
// Log de performance end-to-end + tramos principales (para diagnosticar "dónde se va el tiempo")
|
||||||
tenant_id: tenantId,
|
console.log("[perf] processMessage", {
|
||||||
wa_chat_id: chat_id,
|
tenantId,
|
||||||
limit: 20,
|
chat_id,
|
||||||
});
|
provider,
|
||||||
const compactHistory = collapseAssistantMessages(history);
|
message_id,
|
||||||
|
run_id,
|
||||||
const llmInput = {
|
end_to_end_ms,
|
||||||
wa_chat_id: chat_id,
|
ms: {
|
||||||
last_user_message: text,
|
db_state_ms: msBetween("start", "after_getConversationState"),
|
||||||
conversation_history: compactHistory,
|
db_identity_ms: msBetween("after_getConversationState", "after_getExternalCustomerIdByChat"),
|
||||||
current_conversation_state: prev_state,
|
insert_in_ms: msBetween("after_getExternalCustomerIdByChat", "after_insertMessage_in"),
|
||||||
context: {
|
insert_run_ms: msBetween("before_insertRun", "after_insertRun"),
|
||||||
...(prev?.context || {}),
|
insert_out_ms: msBetween("after_insertRun", "after_insertMessage_out"),
|
||||||
external_customer_id: externalCustomerId ?? prev?.context?.external_customer_id ?? null,
|
woo_customer_ms: msBetween("before_ensureWooCustomer", "after_ensureWooCustomer"),
|
||||||
|
upsert_state_ms: msBetween("after_ensureWooCustomer", "after_upsertConversationState"),
|
||||||
|
history_ms: msBetween("after_upsertConversationState", "after_getRecentMessagesForLLM"),
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
return { run_id, reply: plan.reply };
|
return { run_id, reply: plan.reply };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +1,341 @@
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { getDecryptedTenantEcommerceConfig } from "../db/repo.js";
|
import { getDecryptedTenantEcommerceConfig } from "../db/repo.js";
|
||||||
|
|
||||||
async function fetchWoo({ url, method = "GET", body = null, timeout = 8000 }) {
|
// --- Simple in-memory lock to serialize work per key (e.g. wa_chat_id) ---
|
||||||
const controller = new AbortController();
|
const locks = new Map();
|
||||||
const t = setTimeout(() => controller.abort(), timeout);
|
|
||||||
|
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 {
|
try {
|
||||||
const res = await fetch(url, {
|
return await fn({ lock_wait_ms: acquiredAt - queuedAt });
|
||||||
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 {
|
} finally {
|
||||||
clearTimeout(t);
|
release();
|
||||||
|
// cleanup
|
||||||
|
if (locks.get(key) === next) locks.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createWooCustomer({ tenantId, wa_chat_id, phone, name }) {
|
function sleep(ms) {
|
||||||
const encryptionKey = process.env.APP_ENCRYPTION_KEY;
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
if (!encryptionKey) throw new Error("APP_ENCRYPTION_KEY is required to decrypt Woo credentials");
|
}
|
||||||
|
|
||||||
const cfg = await getDecryptedTenantEcommerceConfig({
|
function getDeep(obj, path) {
|
||||||
tenant_id: tenantId,
|
let cur = obj;
|
||||||
provider: "woo",
|
for (const k of path) {
|
||||||
encryption_key: encryptionKey,
|
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,
|
||||||
|
});
|
||||||
|
console.log("woo headers in", Date.now() - t0, "ms", res.status);
|
||||||
|
const text = await res.text();
|
||||||
|
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 = 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.url = url;
|
||||||
|
err.method = method;
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (!cfg) throw new Error("Woo config not found for tenant");
|
if (Array.isArray(data) && data.length > 0) return data[0];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const consumerKey =
|
async function searchWooCustomerByUsername({ base, consumerKey, consumerSecret, username, timeout }) {
|
||||||
cfg.consumer_key ||
|
const url = `${base}/customers?search=${encodeURIComponent(username)}`;
|
||||||
process.env.WOO_CONSUMER_KEY ||
|
const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString("base64");
|
||||||
(() => {
|
const data = await fetchWoo({
|
||||||
throw new Error("consumer_key not set");
|
url,
|
||||||
})();
|
method: "GET",
|
||||||
const consumerSecret =
|
timeout,
|
||||||
cfg.consumer_secret ||
|
headers: { Authorization: `Basic ${auth}` },
|
||||||
process.env.WOO_CONSUMER_SECRET ||
|
});
|
||||||
(() => {
|
if (Array.isArray(data) && data.length > 0) {
|
||||||
throw new Error("consumer_secret not set");
|
const exact = data.find((c) => c.username === username);
|
||||||
})();
|
return exact || data[0];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const base = cfg.base_url.replace(/\/+$/, "");
|
export async function createWooCustomer({ tenantId, wa_chat_id, phone, name }) {
|
||||||
const url = `${base}/customers?consumer_key=${encodeURIComponent(
|
const lockKey = `${tenantId}:${wa_chat_id}`;
|
||||||
consumerKey
|
const t0 = Date.now();
|
||||||
)}&consumer_secret=${encodeURIComponent(consumerSecret)}`;
|
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 payload = {
|
const cfg = await getDecryptedTenantEcommerceConfig({
|
||||||
email: `${phone || wa_chat_id}@no-email.local`,
|
tenant_id: tenantId,
|
||||||
first_name: name || phone || wa_chat_id,
|
provider: "woo",
|
||||||
username: phone || wa_chat_id,
|
encryption_key: encryptionKey,
|
||||||
password: crypto.randomBytes(12).toString("base64url"), // requerido por Woo
|
});
|
||||||
billing: {
|
if (!cfg) throw new Error("Woo config not found for tenant");
|
||||||
phone: phone || wa_chat_id,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const data = await fetchWoo({ url, method: "POST", body: payload, timeout: cfg.timeout_ms });
|
const consumerKey =
|
||||||
return { id: data?.id, raw: data };
|
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 }) {
|
export async function getWooCustomerById({ tenantId, id }) {
|
||||||
|
|||||||
Reference in New Issue
Block a user