Mono-tenant: resolver id una vez al boot, eliminar lookups por turno

El sistema nunca fue realmente multi-tenant en la práctica. El esquema
DB conserva las columnas tenant_id (queda lista para escalar más adelante
sin migración), pero la app ahora resuelve el tenant una sola vez al
arranque y todas las capas leen de un único punto.

- src/modules/shared/tenant.js: nuevo módulo. setTenant() en boot,
  getTenantId() lo lee desde cualquier lado.
- index.js: ensureTenant() → setTenant({ id, key }). Sin cambios externos.
- pipeline.resolveTenantId(): pasa de hacer 1-2 queries a DB por turno
  a un return sincrónico del id cacheado. Mantiene firma async para no
  romper callers.
- intake handlers (sim.js, evolution.js): usan getTenantId() directo,
  sin parsing de tenant_key del chat_id ni lookup por canal.
- wooWebhooks: ya no requiere ?tenant_key=... en la query string.
  El webhook va al único tenant configurado.
- repo.js: eliminados getTenantByKey() y getTenantIdByChannel() (no más
  callers).

Plumbing del parámetro tenantId en signatures de handlers/repos/machine
queda intacto — bajar eso es ruido de alto riesgo y no aporta hoy.
188 tests pasando.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lucas Tettamanti
2026-05-01 21:00:22 -03:00
parent 17cea4aa9e
commit 6b7889ef4e
9 changed files with 56 additions and 76 deletions

View File

@@ -0,0 +1 @@
{"sessionId":"b4e65d8e-b477-417b-9390-f3d14033ef91","pid":3138376,"procStart":"71699559","acquiredAt":1777673126273}

View File

@@ -40,7 +40,9 @@ Repetitive, hardcoded responses are a known quality problem and the focus of the
## Architecture
This is a **multi-tenant WhatsApp e-commerce chatbot** powered by Express.js. Tenants are WooCommerce store operators; their customers interact via WhatsApp to browse products, build carts, and place orders. All database operations are isolated by `tenant_id`.
This is a **mono-tenant WhatsApp e-commerce chatbot** powered by Express.js. The store operator hooks the bot to a single WooCommerce shop; customers interact via WhatsApp to browse products, build carts, and place orders.
The DB schema retains `tenant_id` columns (it was originally multi-tenant) but the app boots with a single tenant resolved at startup. The single id is exposed via `src/modules/shared/tenant.js` (`getTenantId()`); webhook handlers and intake routes read from there instead of looking up tenants per-request.
### Request flow

View File

@@ -1,10 +1,10 @@
import "dotenv/config";
import { ensureTenant } from "./src/modules/2-identity/db/repo.js";
import { setTenant } from "./src/modules/shared/tenant.js";
import { createApp } from "./src/app.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).
// Node 18+ usa undici debajo de fetch. Esto suele arreglar timeouts "fantasma" por keep-alive/pooling.
try {
const { setGlobalDispatcher, Agent } = await import("undici");
setGlobalDispatcher(
@@ -21,21 +21,14 @@ async function configureUndiciDispatcher() {
}
}
/**
* --- Tenant ---
*/
const TENANT_KEY = process.env.TENANT_KEY || "piaf";
let TENANT_ID = null;
/**
* --- Boot ---
*/
const port = process.env.PORT || 3000;
(async function boot() {
await configureUndiciDispatcher();
TENANT_ID = await ensureTenant({ key: TENANT_KEY, name: TENANT_KEY.toUpperCase() });
const app = createApp({ tenantId: TENANT_ID });
const tenantId = await ensureTenant({ key: TENANT_KEY, name: TENANT_KEY.toUpperCase() });
setTenant({ id: tenantId, key: TENANT_KEY });
const app = createApp({ tenantId });
app.listen(port, () => console.log(`UI: http://localhost:${port} (tenant=${TENANT_KEY})`));
})().catch((err) => {
console.error("Boot failed:", err);

View File

@@ -1,29 +1,25 @@
import crypto from "crypto";
import { parseEvolutionWebhook } from "../services/evolutionParser.js";
import { resolveTenantId, processMessage } from "../../2-identity/services/pipeline.js";
import { processMessage } from "../../2-identity/services/pipeline.js";
import { getTenantId } from "../../shared/tenant.js";
import { debug as dbg } from "../../shared/debug.js";
export async function handleEvolutionWebhook(body) {
const t0 = Date.now();
const parsed = parseEvolutionWebhook(body);
if (!parsed.ok) {
if (!parsed.ok) {
return { status: 200, payload: { ok: true, ignored: parsed.reason } };
}
if (dbg.perf || dbg.evolution) {
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({
chat_id: parsed.chat_id,
tenant_key: parsed.tenant_key,
to_phone: null,
});
const tenantId = getTenantId();
const pm = await processMessage({
tenantId,
@@ -33,7 +29,7 @@ if (!parsed.ok) {
text: parsed.text,
provider: "evolution",
message_id: parsed.message_id || crypto.randomUUID(),
meta: { pushName: parsed.from_name, ts: parsed.ts, instance: parsed.tenant_key, source: parsed.source },
meta: { pushName: parsed.from_name, ts: parsed.ts, source: parsed.source },
});
if (dbg.perf || dbg.evolution) {
@@ -48,4 +44,3 @@ if (!parsed.ok) {
return { status: 200, payload: { ok: true } };
}

View File

@@ -1,6 +1,6 @@
import crypto from "crypto";
import { resolveTenantId } from "../../2-identity/services/pipeline.js";
import { processMessage } from "../../2-identity/services/pipeline.js";
import { getTenantId } from "../../shared/tenant.js";
export async function handleSimSend(body) {
const { chat_id, from_phone, text } = body || {};
@@ -10,11 +10,7 @@ export async function handleSimSend(body) {
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 tenantId = getTenantId();
const result = await processMessage({
tenantId,
@@ -27,4 +23,3 @@ export async function handleSimSend(body) {
return { status: 200, payload: { ok: true, run_id: result.run_id, reply: result.reply } };
}

View File

@@ -1,5 +1,5 @@
import { refreshProductByWooId } from "../../shared/wooSnapshot.js";
import { getTenantByKey } from "../db/repo.js";
import { getTenantId } from "../../shared/tenant.js";
import { insertAuditLog } from "../../0-ui/db/repo.js";
function unauthorized(res) {
@@ -48,11 +48,8 @@ export function makeWooProductWebhook() {
const { id, parentId, resource, action, changes } = parseWooPayload(req.body || {});
if (!id) return res.status(400).json({ ok: false, error: "missing_id" });
// Determinar tenant por query ?tenant_key=...
const tenantKey = req.query?.tenant_key || process.env.TENANT_KEY || null;
if (!tenantKey) return res.status(400).json({ ok: false, error: "missing_tenant_key" });
const tenant = await getTenantByKey(String(tenantKey).toLowerCase());
if (!tenant?.id) return res.status(404).json({ ok: false, error: "tenant_not_found" });
// Mono-tenant: el tenant es el único cargado al boot.
const tenant = { id: getTenantId() };
const parentForVariation =
resource && String(resource).includes("variation") ? parentId || null : null;

View File

@@ -461,21 +461,6 @@ export async function deleteIdentityMapByChat({ tenant_id, wa_chat_id, provider
return rowCount || 0;
}
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

View File

@@ -8,9 +8,8 @@ import {
getExternalCustomerIdByChat,
upsertExternalCustomerMap,
updateRunLatency,
getTenantByKey,
getTenantIdByChannel,
} from "../db/repo.js";
import { getTenantId } from "../../shared/tenant.js";
import { sseSend } from "../../shared/sse.js";
import { createWooCustomer, getWooCustomerById } from "./woo.js";
import { debug as dbg } from "../../shared/debug.js";
@@ -469,28 +468,11 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
return { run_id, reply: plan.reply };
}
function parseTenantFromChatId(chat_id) {
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 }) {
const explicit = (tenant_key || parseTenantFromChatId(chat_id) || "").toLowerCase();
if (explicit) {
const t = await getTenantByKey(explicit);
if (t) return t.id;
throw new Error(`tenant_not_found: ${explicit}`);
}
if (to_phone) {
const id = await getTenantIdByChannel({ channel_type: "whatsapp", channel_key: to_phone });
if (id) return id;
}
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}`);
/**
* Mono-tenant: devuelve el id resuelto al boot. No hace queries por turno.
* Se mantiene como async para no romper callers existentes.
*/
export async function resolveTenantId() {
return getTenantId();
}

View File

@@ -0,0 +1,30 @@
/**
* Mono-tenant: el bot opera con un único tenant resuelto al boot.
*
* Toda la infra DB sigue con columnas `tenant_id` (queda lista para
* multi-tenant futuro) pero la app se inicializa con un único ID
* y todas las llamadas a "resolver el tenant" devuelven ese mismo.
*
* Setup en boot (index.js): `setTenantId(uuid)` después de ensureTenant.
* Lectura en cualquier capa: `getTenantId()`.
*/
let _tenantId = null;
let _tenantKey = null;
export function setTenant({ id, key }) {
if (!id) throw new Error("setTenant requires id");
_tenantId = id;
_tenantKey = key || null;
}
export function getTenantId() {
if (!_tenantId) {
throw new Error("tenant not initialized: call setTenant() at boot");
}
return _tenantId;
}
export function getTenantKey() {
return _tenantKey;
}