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