woocommerce integration, controllers and handlers ready, evolution api simulator ready
This commit is contained in:
9
TODO.md
Normal file
9
TODO.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# TODOs
|
||||||
|
|
||||||
|
- Integrar WooCommerce real en `src/services/woo.js` (reemplazar stub `createWooCustomer` con llamadas a la API y manejo de errores; usar creds/config desde env).
|
||||||
|
- Pipeline: cuando Woo devuelva el cliente real, mantener/actualizar el mapping en `wa_identity_map` vía `upsertWooCustomerMap`.
|
||||||
|
- Conectar con OpenAI en `src/services/pipeline.js` usando `llmInput` y validar el output con esquema (Zod) antes de guardar el run.
|
||||||
|
- (Opcional) Endpoint interno para forzar/upsert de mapping Woo ↔ wa_chat_id, reutilizando repo/woo service.
|
||||||
|
- Revisar manejo de multi-tenant en simulador/UI (instance/tenant_key) y asegurar consistencia en `resolveTenantId`/webhooks.
|
||||||
|
- Enterprise: mover credenciales de Woo (u otras tiendas) a secret manager (Vault/AWS SM/etc.), solo referenciarlas desde DB por clave/ID; auditar acceso a secretos y mapping; soportar rotación de keys.
|
||||||
|
- Enterprise: tabla de config por tenant para e-commerce genérico (tipo, base_url, cred_ref/secret_ref, timeout), con soporte a Woo/otros; sin almacenar secretos en claro.
|
||||||
13
db/migrations/20260102183608_tenant_config_change.sql
Normal file
13
db/migrations/20260102183608_tenant_config_change.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- migrate:up
|
||||||
|
ALTER TABLE tenant_ecommerce_config DROP COLUMN credential_secret;
|
||||||
|
ALTER TABLE tenant_ecommerce_config RENAME COLUMN credential_key TO credential_ref;
|
||||||
|
ALTER TABLE tenant_ecommerce_config ADD COLUMN enc_consumer_key bytea;
|
||||||
|
ALTER TABLE tenant_ecommerce_config ADD COLUMN enc_consumer_secret bytea;
|
||||||
|
ALTER TABLE tenant_ecommerce_config ADD COLUMN encryption_salt bytea DEFAULT gen_random_bytes(16);
|
||||||
|
|
||||||
|
-- migrate:down
|
||||||
|
ALTER TABLE tenant_ecommerce_config DROP COLUMN encryption_salt;
|
||||||
|
ALTER TABLE tenant_ecommerce_config DROP COLUMN enc_consumer_secret;
|
||||||
|
ALTER TABLE tenant_ecommerce_config DROP COLUMN enc_consumer_key;
|
||||||
|
ALTER TABLE tenant_ecommerce_config RENAME COLUMN credential_ref TO credential_key;
|
||||||
|
ALTER TABLE tenant_ecommerce_config ADD COLUMN credential_secret text NOT NULL;
|
||||||
87
index.js
87
index.js
@@ -1,12 +1,15 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import crypto from "crypto";
|
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { ensureTenant, listConversations, listRuns, getRunById } from "./src/db/repo.js";
|
import { ensureTenant } from "./src/db/repo.js";
|
||||||
import { addSseClient, removeSseClient, sseSend } from "./src/services/sse.js";
|
import { addSseClient, removeSseClient } from "./src/services/sse.js";
|
||||||
import { processMessage } from "./src/services/pipeline.js";
|
import { makeGetConversations } from "./src/controllers/conversations.js";
|
||||||
|
import { makeListRuns, makeGetRunById } from "./src/controllers/runs.js";
|
||||||
|
import { makeSimSend } from "./src/controllers/sim.js";
|
||||||
|
import { makeEvolutionWebhook } from "./src/controllers/evolution.js";
|
||||||
|
import { makeGetConversationState } from "./src/controllers/conversationState.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
@@ -46,79 +49,19 @@ app.get("/stream", (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* --- Simulator ---
|
|
||||||
* POST /sim/send { chat_id, from_phone, text }
|
|
||||||
*/
|
|
||||||
app.post("/sim/send", async (req, res) => {
|
|
||||||
const { chat_id, from_phone, text } = req.body || {};
|
|
||||||
if (!chat_id || !from_phone || !text) {
|
|
||||||
return res.status(400).json({ ok: false, error: "chat_id, from_phone, text are required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const provider = "sim";
|
|
||||||
const message_id = crypto.randomUUID(); // idempotencia por mensaje sim
|
|
||||||
const result = await processMessage({
|
|
||||||
tenantId: TENANT_ID,
|
|
||||||
chat_id,
|
|
||||||
from: from_phone,
|
|
||||||
text,
|
|
||||||
provider,
|
|
||||||
message_id,
|
|
||||||
});
|
|
||||||
res.json({ ok: true, run_id: result.run_id, reply: result.reply });
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
res.status(500).json({ ok: false, error: "internal_error", detail: String(err?.message || err) });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* --- UI data endpoints ---
|
* --- UI data endpoints ---
|
||||||
*/
|
*/
|
||||||
app.get("/conversations", async (req, res) => {
|
app.post("/sim/send", makeSimSend());
|
||||||
const { q = "", status = "", state = "", limit = "50" } = req.query;
|
|
||||||
try {
|
|
||||||
const items = await listConversations({
|
|
||||||
tenant_id: TENANT_ID,
|
|
||||||
q: String(q || ""),
|
|
||||||
status: String(status || ""),
|
|
||||||
state: String(state || ""),
|
|
||||||
limit: parseInt(limit, 10) || 50,
|
|
||||||
});
|
|
||||||
res.json({ items });
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
res.status(500).json({ ok: false, error: "internal_error" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/runs", async (req, res) => {
|
app.get("/conversations", makeGetConversations(() => TENANT_ID));
|
||||||
const { chat_id = null, limit = "50" } = req.query;
|
app.get("/conversations/state", makeGetConversationState(() => TENANT_ID));
|
||||||
try {
|
|
||||||
const items = await listRuns({
|
|
||||||
tenant_id: TENANT_ID,
|
|
||||||
wa_chat_id: chat_id ? String(chat_id) : null,
|
|
||||||
limit: parseInt(limit, 10) || 50,
|
|
||||||
});
|
|
||||||
res.json({ items });
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
res.status(500).json({ ok: false, error: "internal_error" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/runs/:run_id", async (req, res) => {
|
app.get("/runs", makeListRuns(() => TENANT_ID));
|
||||||
try {
|
|
||||||
const run = await getRunById({ tenant_id: TENANT_ID, run_id: req.params.run_id });
|
app.get("/runs/:run_id", makeGetRunById(() => TENANT_ID));
|
||||||
if (!run) return res.status(404).json({ ok: false, error: "not_found" });
|
|
||||||
res.json(run);
|
app.post("/webhook/evolution", makeEvolutionWebhook());
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
res.status(500).json({ ok: false, error: "internal_error" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/", (req, res) => {
|
app.get("/", (req, res) => {
|
||||||
res.sendFile(path.join(publicDir, "index.html"));
|
res.sendFile(path.join(publicDir, "index.html"));
|
||||||
|
|||||||
@@ -23,22 +23,24 @@ class ChatSimulator extends HTMLElement {
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<div class="muted">Simulador WhatsApp (local)</div>
|
<div class="muted">Evolution Sim (único chat)</div>
|
||||||
<div class="row" style="margin-top:8px">
|
<div class="row" style="margin-top:8px">
|
||||||
<input id="from" style="flex:1" value="+5491100000000" placeholder="+54911xxxxxxxx" />
|
<input id="instance" style="flex:1" value="Piaf" placeholder="tenant/instance key" />
|
||||||
</div>
|
</div>
|
||||||
<div class="row" style="margin-top:8px">
|
<div class="row" style="margin-top:8px">
|
||||||
<input id="chat" style="flex:1" value="sim:+5491100000000" placeholder="chat_id" />
|
<input id="evoFrom" style="flex:1" value="5491133230322@s.whatsapp.net" placeholder="from (remoteJid)" />
|
||||||
<button id="reset">Reset</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row" style="margin-top:8px">
|
||||||
|
<input id="evoTo" style="flex:1" value="5491137887040@s.whatsapp.net" placeholder="to (destino receptor)" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row" style="margin-top:8px">
|
||||||
<div class="box">
|
<input id="pushName" style="flex:1" value="SimUser" placeholder="pushName (opcional)" />
|
||||||
<div class="muted">Chat</div>
|
</div>
|
||||||
|
<div class="muted" style="margin-top:8px">Chat</div>
|
||||||
<div class="chatlog" id="log"></div>
|
<div class="chatlog" id="log"></div>
|
||||||
<textarea id="text" placeholder="Escribí como cliente…"></textarea>
|
<textarea id="evoText" placeholder="Texto a enviar por Evolution…"></textarea>
|
||||||
<div class="row" style="margin-top:8px">
|
<div class="row" style="margin-top:8px">
|
||||||
<button class="primary" id="send" style="flex:1">Send</button>
|
<button class="primary" id="sendEvo" style="flex:1">Send</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -50,47 +52,88 @@ class ChatSimulator extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
const fromEl = this.shadowRoot.getElementById("from");
|
const evoInstanceEl = this.shadowRoot.getElementById("instance");
|
||||||
const chatEl = this.shadowRoot.getElementById("chat");
|
const evoFromEl = this.shadowRoot.getElementById("evoFrom");
|
||||||
const resetEl = this.shadowRoot.getElementById("reset");
|
const evoToEl = this.shadowRoot.getElementById("evoTo");
|
||||||
const sendEl = this.shadowRoot.getElementById("send");
|
const evoPushEl = this.shadowRoot.getElementById("pushName");
|
||||||
|
const evoTextEl = this.shadowRoot.getElementById("evoText");
|
||||||
|
const sendEvoEl = this.shadowRoot.getElementById("sendEvo");
|
||||||
|
|
||||||
resetEl.onclick = () => {
|
const sendAction = async () => {
|
||||||
this.shadowRoot.getElementById("log").innerHTML = "";
|
const instance = evoInstanceEl.value.trim() || "Piaf";
|
||||||
this.shadowRoot.getElementById("raw").textContent = "—";
|
const from = evoFromEl.value.trim() || "5491133230322@s.whatsapp.net"; // cliente
|
||||||
const phone = (fromEl.value || "+5491100000000").trim();
|
const to = evoToEl.value.trim() || "5491137887040@s.whatsapp.net"; // canal/destino
|
||||||
chatEl.value = `sim:${phone}`;
|
const text = evoTextEl.value.trim();
|
||||||
this.append("bot", "Chat reseteado (solo UI). Enviá un mensaje para generar runs.");
|
const pushName = evoPushEl.value.trim();
|
||||||
};
|
|
||||||
|
|
||||||
sendEl.onclick = async () => {
|
if (!from || !text) {
|
||||||
const text = this.shadowRoot.getElementById("text").value.trim();
|
alert("Falta from o text");
|
||||||
if (!text) return;
|
|
||||||
|
|
||||||
const from_phone = fromEl.value.trim();
|
|
||||||
const chat_id = chatEl.value.trim();
|
|
||||||
if (!from_phone || !chat_id) return alert("Falta teléfono o chat_id");
|
|
||||||
|
|
||||||
this.append("user", text);
|
|
||||||
this.shadowRoot.getElementById("text").value = "";
|
|
||||||
|
|
||||||
const data = await api.simSend({ chat_id, from_phone, text });
|
|
||||||
this.shadowRoot.getElementById("raw").textContent = JSON.stringify(data, null, 2);
|
|
||||||
|
|
||||||
if (!data.ok) {
|
|
||||||
this.append("bot", "Error en simulación.");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.append("bot", data.reply);
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
emit("ui:selectedChat", { chat_id });
|
const genId = () =>
|
||||||
|
(self.crypto?.randomUUID?.() || `${Date.now()}${Math.random()}`)
|
||||||
|
.replace(/-/g, "")
|
||||||
|
.slice(0, 22)
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
body: {
|
||||||
|
event: "messages.upsert",
|
||||||
|
instance,
|
||||||
|
data: {
|
||||||
|
key: {
|
||||||
|
// remoteJid debe ser el cliente (buyer)
|
||||||
|
remoteJid: from,
|
||||||
|
fromMe: false,
|
||||||
|
id: genId(),
|
||||||
|
participant: "",
|
||||||
|
addressingMode: "pn",
|
||||||
|
},
|
||||||
|
pushName: pushName || "SimUser",
|
||||||
|
status: "DELIVERY_ACK",
|
||||||
|
message: { conversation: text },
|
||||||
|
messageType: "conversation",
|
||||||
|
messageTimestamp: nowSec,
|
||||||
|
instanceId: genId(),
|
||||||
|
source: "sim",
|
||||||
|
},
|
||||||
|
date_time: new Date().toISOString(),
|
||||||
|
sender: from,
|
||||||
|
server_url: "http://localhost",
|
||||||
|
apikey: "SIM",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// si querés, cuando llega un upsert de conversación simulada, podés auto-seleccionarla
|
const data = await api.simEvolution(payload);
|
||||||
|
this.shadowRoot.getElementById("raw").textContent = JSON.stringify(data, null, 2);
|
||||||
|
console.log("[evolution sim] webhook response:", data);
|
||||||
|
|
||||||
|
if (!data.ok) {
|
||||||
|
this.append("bot", "Error en Evolution Sim.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("ui:selectedChat", { chat_id: from });
|
||||||
|
this.append("user", text);
|
||||||
|
this.append("bot", `[Evolution] enviado (sim): ${text}`);
|
||||||
|
evoTextEl.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
sendEvoEl.onclick = sendAction;
|
||||||
|
|
||||||
|
evoTextEl.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendAction();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this._unsub = on("conversation:upsert", (c) => {
|
this._unsub = on("conversation:upsert", (c) => {
|
||||||
const chat_id = this.shadowRoot.getElementById("chat").value.trim();
|
const chat_id = evoFromEl.value.trim() || "5491133230322@s.whatsapp.net";
|
||||||
if (c.chat_id === chat_id) {
|
if (c.chat_id === chat_id) {
|
||||||
// no-op, pero podrías reflejar estado/intent acá si querés
|
// placeholder: podrías reflejar estado/intent acá si querés
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ export const api = {
|
|||||||
return fetch(u).then(r => r.json());
|
return fetch(u).then(r => r.json());
|
||||||
},
|
},
|
||||||
|
|
||||||
async simSend({ chat_id, from_phone, text }) {
|
async simEvolution(payload) {
|
||||||
return fetch("/sim/send", {
|
return fetch("/webhook/evolution", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ chat_id, from_phone, text }),
|
body: JSON.stringify(payload),
|
||||||
}).then(r => r.json());
|
}).then(r => r.json());
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
16
src/controllers/conversationState.js
Normal file
16
src/controllers/conversationState.js
Normal 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" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
13
src/controllers/conversations.js
Normal file
13
src/controllers/conversations.js
Normal 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" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
12
src/controllers/evolution.js
Normal file
12
src/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" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
29
src/controllers/runs.js
Normal file
29
src/controllers/runs.js
Normal 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
12
src/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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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({
|
export async function getRecentMessagesForLLM({
|
||||||
tenant_id,
|
tenant_id,
|
||||||
wa_chat_id,
|
wa_chat_id,
|
||||||
@@ -285,3 +266,77 @@ export async function getRecentMessagesForLLM({
|
|||||||
content: String(r.text).trim().slice(0, maxCharsPerMessage),
|
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;
|
||||||
|
}
|
||||||
21
src/handlers/conversationState.js
Normal file
21
src/handlers/conversationState.js
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
13
src/handlers/conversations.js
Normal file
13
src/handlers/conversations.js
Normal 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
29
src/handlers/evolution.js
Normal 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
14
src/handlers/runs.js
Normal 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
30
src/handlers/sim.js
Normal 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 } };
|
||||||
|
}
|
||||||
|
|
||||||
56
src/services/evolutionParser.js
Normal file
56
src/services/evolutionParser.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,8 +5,11 @@ import {
|
|||||||
insertRun,
|
insertRun,
|
||||||
upsertConversationState,
|
upsertConversationState,
|
||||||
getRecentMessagesForLLM,
|
getRecentMessagesForLLM,
|
||||||
|
getExternalCustomerIdByChat,
|
||||||
|
upsertExternalCustomerMap,
|
||||||
} from "../db/repo.js";
|
} from "../db/repo.js";
|
||||||
import { sseSend } from "./sse.js";
|
import { sseSend } from "./sse.js";
|
||||||
|
import { createWooCustomer, getWooCustomerById } from "./woo.js";
|
||||||
|
|
||||||
|
|
||||||
function nowIso() {
|
function nowIso() {
|
||||||
@@ -20,7 +23,15 @@ function newId(prefix = "run") {
|
|||||||
export async function processMessage({ tenantId, chat_id, from, text, provider, message_id }) {
|
export async function processMessage({ tenantId, chat_id, from, text, provider, message_id }) {
|
||||||
const started_at = Date.now();
|
const started_at = Date.now();
|
||||||
const prev = await getConversationState(tenantId, chat_id);
|
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({
|
await insertMessage({
|
||||||
tenant_id: tenantId,
|
tenant_id: tenantId,
|
||||||
@@ -79,9 +90,37 @@ export async function processMessage({ tenantId, chat_id, from, text, provider,
|
|||||||
run_id,
|
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 = {
|
const context = {
|
||||||
missing_fields: plan.missing_fields || [],
|
missing_fields: plan.missing_fields || [],
|
||||||
basket_resolved: plan.basket_resolved || { items: [] },
|
basket_resolved: plan.basket_resolved || { items: [] },
|
||||||
|
external_customer_id: externalCustomerId ?? prev?.context?.external_customer_id ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const stateRow = await upsertConversationState({
|
const stateRow = await upsertConversationState({
|
||||||
@@ -121,17 +160,21 @@ export async function processMessage({ tenantId, chat_id, from, text, provider,
|
|||||||
});
|
});
|
||||||
|
|
||||||
const history = await getRecentMessagesForLLM({
|
const history = await getRecentMessagesForLLM({
|
||||||
tenant_id: TENANT_ID,
|
tenant_id: tenantId,
|
||||||
wa_chat_id: chat_id,
|
wa_chat_id: chat_id,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
});
|
});
|
||||||
|
const compactHistory = collapseAssistantMessages(history);
|
||||||
|
|
||||||
const llmInput = {
|
const llmInput = {
|
||||||
wa_chat_id: chat_id,
|
wa_chat_id: chat_id,
|
||||||
last_user_message: text,
|
last_user_message: text,
|
||||||
conversation_history: history,
|
conversation_history: compactHistory,
|
||||||
current_conversation_state: prev_state,
|
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 };
|
return { run_id, reply: plan.reply };
|
||||||
@@ -147,3 +190,34 @@ export function collapseAssistantMessages(messages) {
|
|||||||
return out;
|
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
114
src/services/woo.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user