El operador puede cargar el base_url con o sin /wp-json/wc/v3. El código anterior siempre concatenaba el sufijo, lo que producía URLs duplicadas tipo https://store/wp-json/wc/v3/wp-json/wc/v3/products → 404 (rest_no_route). Nuevo helper buildWooApiBase detecta si la URL ya incluye /wp-json/ y respeta lo que vino. Centraliza la lógica en un solo lugar consumido por los tres clientes Woo (wooSnapshot, wooOrders, woo.js). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
435 lines
13 KiB
JavaScript
435 lines
13 KiB
JavaScript
import crypto from "crypto";
|
|
import { getDecryptedTenantEcommerceConfig } from "../db/repo.js";
|
|
import { debug } from "../../shared/debug.js";
|
|
import { buildWooApiBase } from "../../shared/wooApiBase.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 = buildWooApiBase(cfg.base_url, cfg.api_version);
|
|
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 };
|
|
}
|
|
|