separated in modules
This commit is contained in:
4
TODO.md
4
TODO.md
@@ -1,8 +1,8 @@
|
|||||||
# TODOs
|
# 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).
|
- Integrar WooCommerce real en `src/modules/2-identity/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`.
|
- 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. (Hecho) — env: `OPENAI_API_KEY`, opcional `OPENAI_MODEL`.
|
- Conectar con OpenAI en `src/modules/2-identity/services/pipeline.js` usando `llmInput` y validar el output con esquema (Zod) antes de guardar el run. (Hecho) — env: `OPENAI_API_KEY`, opcional `OPENAI_MODEL`.
|
||||||
- (Opcional) Endpoint interno para forzar/upsert de mapping Woo ↔ wa_chat_id, reutilizando repo/woo service.
|
- (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.
|
- 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: 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.
|
||||||
|
|||||||
54
db/migrations/20260114132000_woo_products_snapshot.sql
Normal file
54
db/migrations/20260114132000_woo_products_snapshot.sql
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
-- migrate:up
|
||||||
|
create table if not exists woo_snapshot_runs (
|
||||||
|
id bigserial primary key,
|
||||||
|
tenant_id uuid not null references tenants(id) on delete cascade,
|
||||||
|
source text not null, -- csv | api | webhook
|
||||||
|
total_items integer not null default 0,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists woo_products_snapshot (
|
||||||
|
tenant_id uuid not null references tenants(id) on delete cascade,
|
||||||
|
woo_id integer not null,
|
||||||
|
type text not null, -- simple | variable | variation
|
||||||
|
parent_id integer null,
|
||||||
|
name text not null,
|
||||||
|
slug text null,
|
||||||
|
status text null,
|
||||||
|
catalog_visibility text null,
|
||||||
|
price_regular numeric(12,2) null,
|
||||||
|
price_sale numeric(12,2) null,
|
||||||
|
price_current numeric(12,2) null,
|
||||||
|
stock_status text null,
|
||||||
|
stock_qty integer null,
|
||||||
|
backorders text null,
|
||||||
|
categories jsonb not null default '[]'::jsonb,
|
||||||
|
tags jsonb not null default '[]'::jsonb,
|
||||||
|
attributes_normalized jsonb not null default '{}'::jsonb,
|
||||||
|
date_modified timestamptz null,
|
||||||
|
run_id bigint null references woo_snapshot_runs(id) on delete set null,
|
||||||
|
raw jsonb not null default '{}'::jsonb,
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
primary key (tenant_id, woo_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists woo_snapshot_tenant_type_idx
|
||||||
|
on woo_products_snapshot (tenant_id, type);
|
||||||
|
|
||||||
|
create index if not exists woo_snapshot_tenant_parent_idx
|
||||||
|
on woo_products_snapshot (tenant_id, parent_id);
|
||||||
|
|
||||||
|
create index if not exists woo_snapshot_tenant_status_idx
|
||||||
|
on woo_products_snapshot (tenant_id, status);
|
||||||
|
|
||||||
|
create or replace view sellable_items as
|
||||||
|
select *
|
||||||
|
from woo_products_snapshot
|
||||||
|
where lower(type) in ('simple', 'variation')
|
||||||
|
and coalesce(lower(status), 'publish') = 'publish'
|
||||||
|
and coalesce(lower(catalog_visibility), 'visible') not in ('hidden');
|
||||||
|
|
||||||
|
-- migrate:down
|
||||||
|
drop view if exists sellable_items;
|
||||||
|
drop table if exists woo_products_snapshot;
|
||||||
|
drop table if exists woo_snapshot_runs;
|
||||||
17
db/migrations/20260115123000_drop_woo_products_cache.sql
Normal file
17
db/migrations/20260115123000_drop_woo_products_cache.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- migrate:up
|
||||||
|
drop table if exists woo_products_cache;
|
||||||
|
|
||||||
|
-- migrate:down
|
||||||
|
create table if not exists woo_products_cache (
|
||||||
|
tenant_id uuid not null references tenants(id) on delete cascade,
|
||||||
|
woo_product_id integer not null,
|
||||||
|
name text not null,
|
||||||
|
sku text,
|
||||||
|
price numeric(12,2),
|
||||||
|
currency text,
|
||||||
|
refreshed_at timestamptz not null default now(),
|
||||||
|
payload jsonb not null default '{}'::jsonb,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
primary key (tenant_id, woo_product_id)
|
||||||
|
);
|
||||||
2
index.js
2
index.js
@@ -1,5 +1,5 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { ensureTenant } from "./src/db/repo.js";
|
import { ensureTenant } from "./src/modules/2-identity/db/repo.js";
|
||||||
import { createApp } from "./src/app.js";
|
import { createApp } from "./src/app.js";
|
||||||
|
|
||||||
async function configureUndiciDispatcher() {
|
async function configureUndiciDispatcher() {
|
||||||
|
|||||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"csv-parse": "^6.1.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"openai": "^6.15.0",
|
"openai": "^6.15.0",
|
||||||
@@ -357,6 +358,12 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/csv-parse": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-CEE+jwpgLn+MmtCpVcPtiCZpVtB6Z2OKPTr34pycYYoL7sxdOkXDdQ4lRiw6ioC0q6BLqhc6cKweCVvral8yhw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dbmate": {
|
"node_modules/dbmate": {
|
||||||
"version": "2.28.0",
|
"version": "2.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/dbmate/-/dbmate-2.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/dbmate/-/dbmate-2.28.0.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"csv-parse": "^6.1.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"openai": "^6.15.0",
|
"openai": "^6.15.0",
|
||||||
|
|||||||
222
scripts/import-woo-snapshot.mjs
Normal file
222
scripts/import-woo-snapshot.mjs
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { parse } from "csv-parse/sync";
|
||||||
|
import { pool } from "../src/modules/2-identity/db/pool.js";
|
||||||
|
|
||||||
|
function parseArgs() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const out = { file: null, tenantKey: null, replace: true };
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const a = args[i];
|
||||||
|
if (a === "--file") out.file = args[++i];
|
||||||
|
else if (a === "--tenant-key") out.tenantKey = args[++i];
|
||||||
|
else if (a === "--no-replace") out.replace = false;
|
||||||
|
}
|
||||||
|
if (!out.file) {
|
||||||
|
throw new Error("Usage: node scripts/import-woo-snapshot.mjs --file <path> [--tenant-key <key>] [--no-replace]");
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumber(val) {
|
||||||
|
if (val == null) return null;
|
||||||
|
const s = String(val).replace(/\./g, "").replace(",", ".").trim();
|
||||||
|
if (!s) return null;
|
||||||
|
const n = Number(s);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBool(val) {
|
||||||
|
if (val == null) return null;
|
||||||
|
const s = String(val).trim().toLowerCase();
|
||||||
|
if (s === "1" || s === "si" || s === "sí" || s === "yes" || s === "true") return true;
|
||||||
|
if (s === "0" || s === "no" || s === "false") return false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitList(val) {
|
||||||
|
if (!val) return [];
|
||||||
|
return String(val)
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAttributes(row) {
|
||||||
|
const attrs = {};
|
||||||
|
for (const key of Object.keys(row)) {
|
||||||
|
const m = /^Nombre del atributo (\d+)$/i.exec(key);
|
||||||
|
if (!m) continue;
|
||||||
|
const idx = m[1];
|
||||||
|
const name = String(row[key] || "").trim();
|
||||||
|
if (!name) continue;
|
||||||
|
const valuesKey = `Valor(es) del atributo ${idx}`;
|
||||||
|
const rawValues = row[valuesKey];
|
||||||
|
const values = String(rawValues || "")
|
||||||
|
.split("|")
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
attrs[name.toLowerCase()] = values.length ? values : [String(rawValues || "").trim()].filter(Boolean);
|
||||||
|
}
|
||||||
|
return attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRow(row) {
|
||||||
|
const wooId = Number(row["ID"] || row["Id"] || row["id"] || null);
|
||||||
|
const type = String(row["Tipo"] || "").trim().toLowerCase();
|
||||||
|
const parentId = Number(row["Superior"] || null) || null;
|
||||||
|
const name = String(row["Nombre"] || "").trim();
|
||||||
|
const slug = String(row["Slug"] || row["slug"] || "").trim() || null;
|
||||||
|
const published = parseBool(row["Publicado"]);
|
||||||
|
const status = published === true ? "publish" : published === false ? "draft" : null;
|
||||||
|
const visibility = String(row["Visibilidad en el catálogo"] || "").trim() || null;
|
||||||
|
const priceRegular = parseNumber(row["Precio normal"]);
|
||||||
|
const priceSale = parseNumber(row["Precio rebajado"]);
|
||||||
|
const priceCurrent = priceSale != null ? priceSale : priceRegular;
|
||||||
|
const hasStock = parseBool(row["¿Existencias?"]);
|
||||||
|
const stockQty = parseNumber(row["Inventario"]);
|
||||||
|
const backorders = String(row["¿Permitir reservas de productos agotados?"] || "").trim().toLowerCase() || null;
|
||||||
|
const weightKg = parseNumber(row["Peso (kg)"]);
|
||||||
|
const categories = splitList(row["Categorías"]);
|
||||||
|
const tags = splitList(row["Etiquetas"]);
|
||||||
|
const attributes = extractAttributes(row);
|
||||||
|
if (weightKg != null) attributes["peso_kg"] = [String(weightKg)];
|
||||||
|
|
||||||
|
let stockStatus = null;
|
||||||
|
if (hasStock === true) {
|
||||||
|
if (stockQty == null || stockQty > 0) stockStatus = "instock";
|
||||||
|
else stockStatus = "outofstock";
|
||||||
|
} else if (hasStock === false) {
|
||||||
|
stockStatus = "outofstock";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
woo_id: wooId,
|
||||||
|
type,
|
||||||
|
parent_id: parentId,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
status,
|
||||||
|
catalog_visibility: visibility,
|
||||||
|
price_regular: priceRegular,
|
||||||
|
price_sale: priceSale,
|
||||||
|
price_current: priceCurrent,
|
||||||
|
stock_status: stockStatus,
|
||||||
|
stock_qty: stockQty == null ? null : Math.round(stockQty),
|
||||||
|
backorders,
|
||||||
|
categories,
|
||||||
|
tags,
|
||||||
|
attributes_normalized: attributes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTenants(tenantKey) {
|
||||||
|
if (tenantKey) {
|
||||||
|
const { rows } = await pool.query(`select id, key from tenants where key=$1`, [tenantKey]);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
const { rows } = await pool.query(`select id, key from tenants`);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertRun({ tenantId, total, source }) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`insert into woo_snapshot_runs (tenant_id, source, total_items) values ($1, $2, $3) returning id`,
|
||||||
|
[tenantId, source, total]
|
||||||
|
);
|
||||||
|
return rows[0]?.id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertSnapshotItem({ tenantId, runId, item, raw }) {
|
||||||
|
const q = `
|
||||||
|
insert into woo_products_snapshot
|
||||||
|
(tenant_id, woo_id, type, parent_id, name, slug, status, catalog_visibility,
|
||||||
|
price_regular, price_sale, price_current, stock_status, stock_qty, backorders,
|
||||||
|
categories, tags, attributes_normalized, date_modified, run_id, raw, updated_at)
|
||||||
|
values
|
||||||
|
($1,$2,$3,$4,$5,$6,$7,$8,
|
||||||
|
$9,$10,$11,$12,$13,$14,
|
||||||
|
$15::jsonb,$16::jsonb,$17::jsonb,$18,$19,$20::jsonb,now())
|
||||||
|
on conflict (tenant_id, woo_id)
|
||||||
|
do update set
|
||||||
|
type = excluded.type,
|
||||||
|
parent_id = excluded.parent_id,
|
||||||
|
name = excluded.name,
|
||||||
|
slug = excluded.slug,
|
||||||
|
status = excluded.status,
|
||||||
|
catalog_visibility = excluded.catalog_visibility,
|
||||||
|
price_regular = excluded.price_regular,
|
||||||
|
price_sale = excluded.price_sale,
|
||||||
|
price_current = excluded.price_current,
|
||||||
|
stock_status = excluded.stock_status,
|
||||||
|
stock_qty = excluded.stock_qty,
|
||||||
|
backorders = excluded.backorders,
|
||||||
|
categories = excluded.categories,
|
||||||
|
tags = excluded.tags,
|
||||||
|
attributes_normalized = excluded.attributes_normalized,
|
||||||
|
date_modified = excluded.date_modified,
|
||||||
|
run_id = excluded.run_id,
|
||||||
|
raw = excluded.raw,
|
||||||
|
updated_at = now()
|
||||||
|
`;
|
||||||
|
await pool.query(q, [
|
||||||
|
tenantId,
|
||||||
|
item.woo_id,
|
||||||
|
item.type,
|
||||||
|
item.parent_id,
|
||||||
|
item.name,
|
||||||
|
item.slug,
|
||||||
|
item.status,
|
||||||
|
item.catalog_visibility,
|
||||||
|
item.price_regular,
|
||||||
|
item.price_sale,
|
||||||
|
item.price_current,
|
||||||
|
item.stock_status,
|
||||||
|
item.stock_qty,
|
||||||
|
item.backorders,
|
||||||
|
JSON.stringify(item.categories ?? []),
|
||||||
|
JSON.stringify(item.tags ?? []),
|
||||||
|
JSON.stringify(item.attributes_normalized ?? {}),
|
||||||
|
null,
|
||||||
|
runId,
|
||||||
|
JSON.stringify(raw ?? {}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteMissing({ tenantId, runId }) {
|
||||||
|
await pool.query(
|
||||||
|
`delete from woo_products_snapshot where tenant_id=$1 and coalesce(run_id,0) <> $2`,
|
||||||
|
[tenantId, runId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { file, tenantKey, replace } = parseArgs();
|
||||||
|
const abs = path.resolve(file);
|
||||||
|
const content = fs.readFileSync(abs);
|
||||||
|
const records = parse(content, { columns: true, skip_empty_lines: true });
|
||||||
|
const normalized = records.map((r) => ({ item: normalizeRow(r), raw: r })).filter((r) => r.item.woo_id && r.item.name);
|
||||||
|
|
||||||
|
const tenants = await getTenants(tenantKey);
|
||||||
|
if (!tenants.length) {
|
||||||
|
throw new Error("No tenants found for import");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const t of tenants) {
|
||||||
|
const runId = await insertRun({ tenantId: t.id, total: normalized.length, source: "csv" });
|
||||||
|
for (const row of normalized) {
|
||||||
|
await upsertSnapshotItem({ tenantId: t.id, runId, item: row.item, raw: row.raw });
|
||||||
|
}
|
||||||
|
if (replace && runId) {
|
||||||
|
await deleteMissing({ tenantId: t.id, runId });
|
||||||
|
}
|
||||||
|
console.log(`[import] tenant=${t.key} items=${normalized.length} run_id=${runId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
10
src/app.js
10
src/app.js
@@ -3,15 +3,16 @@ import cors from "cors";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
import { createSimulatorRouter } from "./routes/simulator.js";
|
import { createSimulatorRouter } from "./modules/1-intake/routes/simulator.js";
|
||||||
import { createEvolutionRouter } from "./routes/evolution.js";
|
import { createEvolutionRouter } from "./modules/1-intake/routes/evolution.js";
|
||||||
import { createMercadoPagoRouter } from "./routes/mercadoPago.js";
|
import { createMercadoPagoRouter } from "./modules/6-mercadopago/routes/mercadoPago.js";
|
||||||
|
import { createWooWebhooksRouter } from "./modules/2-identity/routes/wooWebhooks.js";
|
||||||
|
|
||||||
export function createApp({ tenantId }) {
|
export function createApp({ tenantId }) {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json({ limit: "1mb" }));
|
app.use(express.json({ limit: "1mb" }));
|
||||||
|
|
||||||
// Serve /public as static (UI + webcomponents)
|
// Serve /public as static (UI + webcomponents)
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@@ -23,6 +24,7 @@ export function createApp({ tenantId }) {
|
|||||||
app.use(createSimulatorRouter({ tenantId }));
|
app.use(createSimulatorRouter({ tenantId }));
|
||||||
app.use(createEvolutionRouter());
|
app.use(createEvolutionRouter());
|
||||||
app.use("/payments/meli", createMercadoPagoRouter());
|
app.use("/payments/meli", createMercadoPagoRouter());
|
||||||
|
app.use(createWooWebhooksRouter());
|
||||||
|
|
||||||
// Home (UI)
|
// Home (UI)
|
||||||
app.get("/", (req, res) => {
|
app.get("/", (req, res) => {
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import {
|
|||||||
getIdentityMapByChat,
|
getIdentityMapByChat,
|
||||||
getLastInboundMessage,
|
getLastInboundMessage,
|
||||||
listUsers,
|
listUsers,
|
||||||
} from "../db/repo.js";
|
} from "../modules/2-identity/db/repo.js";
|
||||||
import { deleteWooCustomer } from "../services/woo.js";
|
import { deleteWooCustomer } from "../modules/2-identity/services/woo.js";
|
||||||
import { processMessage } from "../services/pipeline.js";
|
import { processMessage } from "../modules/2-identity/services/pipeline.js";
|
||||||
|
|
||||||
export async function handleDeleteConversation({ tenantId, chat_id }) {
|
export async function handleDeleteConversation({ tenantId, chat_id }) {
|
||||||
if (!chat_id) return { ok: false, error: "chat_id_required" };
|
if (!chat_id) return { ok: false, error: "chat_id_required" };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getConversationState } from "../db/repo.js";
|
import { getConversationState } from "../modules/2-identity/db/repo.js";
|
||||||
|
|
||||||
export async function handleGetConversationState({ tenantId, chat_id }) {
|
export async function handleGetConversationState({ tenantId, chat_id }) {
|
||||||
if (!chat_id) {
|
if (!chat_id) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { listConversations } from "../db/repo.js";
|
import { listConversations } from "../modules/2-identity/db/repo.js";
|
||||||
|
|
||||||
export async function handleListConversations({ tenantId, query }) {
|
export async function handleListConversations({ tenantId, query }) {
|
||||||
const { q = "", status = "", state = "", limit = "50" } = query || {};
|
const { q = "", status = "", state = "", limit = "50" } = query || {};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { listMessages } from "../db/repo.js";
|
import { listMessages } from "../modules/2-identity/db/repo.js";
|
||||||
|
|
||||||
export async function handleListMessages({ tenantId, chat_id, limit = "200" }) {
|
export async function handleListMessages({ tenantId, chat_id, limit = "200" }) {
|
||||||
if (!chat_id) return [];
|
if (!chat_id) return [];
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { searchProducts } from "../services/wooProducts.js";
|
import { searchSnapshotItems } from "../modules/shared/wooSnapshot.js";
|
||||||
|
|
||||||
export async function handleSearchProducts({ tenantId, q = "", limit = "10", forceWoo = "0" }) {
|
export async function handleSearchProducts({ tenantId, q = "", limit = "10", forceWoo = "0" }) {
|
||||||
const { items, source } = await searchProducts({
|
const { items, source } = await searchSnapshotItems({
|
||||||
tenantId,
|
tenantId,
|
||||||
q,
|
q,
|
||||||
limit: parseInt(limit, 10) || 10,
|
limit: parseInt(limit, 10) || 10,
|
||||||
forceWoo: String(forceWoo) === "1" || String(forceWoo).toLowerCase() === "true",
|
|
||||||
});
|
});
|
||||||
return { items, source };
|
return { items, source };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { listRuns, getRunById } from "../db/repo.js";
|
import { listRuns, getRunById } from "../modules/2-identity/db/repo.js";
|
||||||
|
|
||||||
export async function handleListRuns({ tenantId, chat_id = null, limit = "50" }) {
|
export async function handleListRuns({ tenantId, chat_id = null, limit = "50" }) {
|
||||||
return listRuns({
|
return listRuns({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { parseEvolutionWebhook } from "../services/evolutionParser.js";
|
import { parseEvolutionWebhook } from "../services/evolutionParser.js";
|
||||||
import { resolveTenantId, processMessage } from "../services/pipeline.js";
|
import { resolveTenantId, processMessage } from "../../2-identity/services/pipeline.js";
|
||||||
import { debug as dbg } from "../services/debug.js";
|
import { debug as dbg } from "../../shared/debug.js";
|
||||||
|
|
||||||
export async function handleEvolutionWebhook(body) {
|
export async function handleEvolutionWebhook(body) {
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { resolveTenantId } from "../services/pipeline.js";
|
import { resolveTenantId } from "../../2-identity/services/pipeline.js";
|
||||||
import { processMessage } from "../services/pipeline.js";
|
import { processMessage } from "../../2-identity/services/pipeline.js";
|
||||||
|
|
||||||
export async function handleSimSend(body) {
|
export async function handleSimSend(body) {
|
||||||
const { chat_id, from_phone, text } = body || {};
|
const { chat_id, from_phone, text } = body || {};
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
|
|
||||||
import { addSseClient, removeSseClient } from "../services/sse.js";
|
import { addSseClient, removeSseClient } from "../../shared/sse.js";
|
||||||
import { makeGetConversations } from "../controllers/conversations.js";
|
import { makeGetConversations } from "../../../controllers/conversations.js";
|
||||||
import { makeListRuns, makeGetRunById } from "../controllers/runs.js";
|
import { makeListRuns, makeGetRunById } from "../../../controllers/runs.js";
|
||||||
import { makeSimSend } from "../controllers/sim.js";
|
import { makeSimSend } from "../controllers/sim.js";
|
||||||
import { makeGetConversationState } from "../controllers/conversationState.js";
|
import { makeGetConversationState } from "../../../controllers/conversationState.js";
|
||||||
import { makeListMessages } from "../controllers/messages.js";
|
import { makeListMessages } from "../../../controllers/messages.js";
|
||||||
import { makeSearchProducts } from "../controllers/products.js";
|
import { makeSearchProducts } from "../../../controllers/products.js";
|
||||||
import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../controllers/admin.js";
|
import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../../controllers/admin.js";
|
||||||
|
|
||||||
function nowIso() {
|
function nowIso() {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
@@ -55,3 +55,4 @@ export function parseEvolutionWebhook(reqBody) {
|
|||||||
raw: body, // para log/debug si querés
|
raw: body, // para log/debug si querés
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
59
src/modules/2-identity/controllers/wooWebhooks.js
Normal file
59
src/modules/2-identity/controllers/wooWebhooks.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { refreshProductByWooId } from "../../shared/wooSnapshot.js";
|
||||||
|
import { getTenantByKey } from "../db/repo.js";
|
||||||
|
|
||||||
|
function unauthorized(res) {
|
||||||
|
res.setHeader("WWW-Authenticate", 'Basic realm="woo-webhook"');
|
||||||
|
return res.status(401).json({ ok: false, error: "unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkBasicAuth(req) {
|
||||||
|
const user = process.env.WOO_WEBHOOK_USER || "";
|
||||||
|
const pass = process.env.WOO_WEBHOOK_PASS || "";
|
||||||
|
const auth = req.headers?.authorization || "";
|
||||||
|
if (!user || !pass) return { ok: false, reason: "missing_env" };
|
||||||
|
if (!auth.startsWith("Basic ")) return { ok: false, reason: "missing_basic" };
|
||||||
|
const decoded = Buffer.from(auth.slice(6), "base64").toString("utf8");
|
||||||
|
const [u, p] = decoded.split(":");
|
||||||
|
if (u === user && p === pass) return { ok: true };
|
||||||
|
return { ok: false, reason: "invalid_creds" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWooId(payload) {
|
||||||
|
const id = payload?.id || payload?.data?.id || null;
|
||||||
|
const parentId = payload?.parent_id || payload?.data?.parent_id || null;
|
||||||
|
const resource = payload?.resource || payload?.topic || null;
|
||||||
|
return { id: id ? Number(id) : null, parentId: parentId ? Number(parentId) : null, resource };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeWooProductWebhook() {
|
||||||
|
return async function handleWooProductWebhook(req, res) {
|
||||||
|
const auth = checkBasicAuth(req);
|
||||||
|
if (!auth.ok) return unauthorized(res);
|
||||||
|
|
||||||
|
const { id, parentId, resource } = parseWooId(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" });
|
||||||
|
|
||||||
|
const parentForVariation =
|
||||||
|
resource && String(resource).includes("variation") ? parentId || null : null;
|
||||||
|
|
||||||
|
const updated = await refreshProductByWooId({
|
||||||
|
tenantId: tenant.id,
|
||||||
|
wooId: id,
|
||||||
|
parentId: parentForVariation,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
ok: true,
|
||||||
|
woo_id: updated?.woo_id || id,
|
||||||
|
type: updated?.type || null,
|
||||||
|
parent_id: updated?.parent_id || null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -288,7 +288,7 @@ export async function getRecentMessagesForLLM({
|
|||||||
`;
|
`;
|
||||||
const { rows } = await pool.query(q, [tenant_id, wa_chat_id, lim]);
|
const { rows } = await pool.query(q, [tenant_id, wa_chat_id, lim]);
|
||||||
|
|
||||||
return rows.reverse().map(r => ({
|
return rows.reverse().map((r) => ({
|
||||||
role: r.direction === "in" ? "user" : "assistant",
|
role: r.direction === "in" ? "user" : "assistant",
|
||||||
content: String(r.text).trim().slice(0, maxCharsPerMessage),
|
content: String(r.text).trim().slice(0, maxCharsPerMessage),
|
||||||
}));
|
}));
|
||||||
@@ -529,98 +529,6 @@ export async function getDecryptedTenantEcommerceConfig({
|
|||||||
return rows[0] || null;
|
return rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertWooProductCache({
|
|
||||||
tenant_id,
|
|
||||||
woo_product_id,
|
|
||||||
name,
|
|
||||||
sku = null,
|
|
||||||
price = null,
|
|
||||||
currency = null,
|
|
||||||
payload = {},
|
|
||||||
refreshed_at = null,
|
|
||||||
}) {
|
|
||||||
const q = `
|
|
||||||
insert into woo_products_cache
|
|
||||||
(tenant_id, woo_product_id, name, sku, price, currency, refreshed_at, payload, created_at, updated_at)
|
|
||||||
values
|
|
||||||
($1, $2, $3, $4, $5, $6, coalesce($7::timestamptz, now()), $8::jsonb, now(), now())
|
|
||||||
on conflict (tenant_id, woo_product_id)
|
|
||||||
do update set
|
|
||||||
name = excluded.name,
|
|
||||||
sku = excluded.sku,
|
|
||||||
price = excluded.price,
|
|
||||||
currency = excluded.currency,
|
|
||||||
refreshed_at = excluded.refreshed_at,
|
|
||||||
payload = excluded.payload,
|
|
||||||
updated_at = now()
|
|
||||||
returning tenant_id, woo_product_id, name, sku, price, currency, refreshed_at, payload, updated_at
|
|
||||||
`;
|
|
||||||
|
|
||||||
const { rows } = await pool.query(q, [
|
|
||||||
tenant_id,
|
|
||||||
woo_product_id,
|
|
||||||
name,
|
|
||||||
sku,
|
|
||||||
price,
|
|
||||||
currency,
|
|
||||||
refreshed_at,
|
|
||||||
JSON.stringify(payload ?? {}),
|
|
||||||
]);
|
|
||||||
return rows[0] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function searchWooProductCache({ tenant_id, q = "", limit = 20 }) {
|
|
||||||
const lim = Math.max(1, Math.min(200, parseInt(limit, 10) || 20));
|
|
||||||
const query = String(q || "").trim();
|
|
||||||
if (!query) return [];
|
|
||||||
|
|
||||||
// Búsqueda simple: name o sku (ilike). Más adelante: trigram/FTS si hace falta.
|
|
||||||
const like = `%${query}%`;
|
|
||||||
const sql = `
|
|
||||||
select tenant_id, woo_product_id, name, sku, price, currency, refreshed_at, payload, updated_at
|
|
||||||
from woo_products_cache
|
|
||||||
where tenant_id=$1
|
|
||||||
and (name ilike $2 or coalesce(sku,'') ilike $2)
|
|
||||||
order by refreshed_at desc
|
|
||||||
limit $3
|
|
||||||
`;
|
|
||||||
const { rows } = await pool.query(sql, [tenant_id, like, lim]);
|
|
||||||
return rows.map((r) => ({
|
|
||||||
tenant_id: r.tenant_id,
|
|
||||||
woo_product_id: r.woo_product_id,
|
|
||||||
name: r.name,
|
|
||||||
sku: r.sku,
|
|
||||||
price: r.price,
|
|
||||||
currency: r.currency,
|
|
||||||
refreshed_at: r.refreshed_at,
|
|
||||||
payload: r.payload,
|
|
||||||
updated_at: r.updated_at,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getWooProductCacheById({ tenant_id, woo_product_id }) {
|
|
||||||
const sql = `
|
|
||||||
select tenant_id, woo_product_id, name, sku, price, currency, refreshed_at, payload, updated_at
|
|
||||||
from woo_products_cache
|
|
||||||
where tenant_id=$1 and woo_product_id=$2
|
|
||||||
limit 1
|
|
||||||
`;
|
|
||||||
const { rows } = await pool.query(sql, [tenant_id, woo_product_id]);
|
|
||||||
const r = rows[0];
|
|
||||||
if (!r) return null;
|
|
||||||
return {
|
|
||||||
tenant_id: r.tenant_id,
|
|
||||||
woo_product_id: r.woo_product_id,
|
|
||||||
name: r.name,
|
|
||||||
sku: r.sku,
|
|
||||||
price: r.price,
|
|
||||||
currency: r.currency,
|
|
||||||
refreshed_at: r.refreshed_at,
|
|
||||||
payload: r.payload,
|
|
||||||
updated_at: r.updated_at,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function searchProductAliases({ tenant_id, q = "", limit = 20 }) {
|
export async function searchProductAliases({ tenant_id, q = "", limit = 20 }) {
|
||||||
const lim = Math.max(1, Math.min(200, parseInt(limit, 10) || 20));
|
const lim = Math.max(1, Math.min(200, parseInt(limit, 10) || 20));
|
||||||
const query = String(q || "").trim();
|
const query = String(q || "").trim();
|
||||||
@@ -737,3 +645,4 @@ export async function getMpPaymentById({ tenant_id, payment_id }) {
|
|||||||
const { rows } = await pool.query(sql, [tenant_id, payment_id]);
|
const { rows } = await pool.query(sql, [tenant_id, payment_id]);
|
||||||
return rows[0] || null;
|
return rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
9
src/modules/2-identity/routes/wooWebhooks.js
Normal file
9
src/modules/2-identity/routes/wooWebhooks.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import express from "express";
|
||||||
|
import { makeWooProductWebhook } from "../controllers/wooWebhooks.js";
|
||||||
|
|
||||||
|
export function createWooWebhooksRouter() {
|
||||||
|
const router = express.Router();
|
||||||
|
router.post("/webhook/woo/products", makeWooProductWebhook());
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
437
src/modules/2-identity/services/pipeline.js
Normal file
437
src/modules/2-identity/services/pipeline.js
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
import {
|
||||||
|
getConversationState,
|
||||||
|
insertMessage,
|
||||||
|
insertRun,
|
||||||
|
touchConversationState,
|
||||||
|
upsertConversationState,
|
||||||
|
getRecentMessagesForLLM,
|
||||||
|
getExternalCustomerIdByChat,
|
||||||
|
upsertExternalCustomerMap,
|
||||||
|
updateRunLatency,
|
||||||
|
getTenantByKey,
|
||||||
|
getTenantIdByChannel,
|
||||||
|
} from "../db/repo.js";
|
||||||
|
import { sseSend } from "../../shared/sse.js";
|
||||||
|
import { createWooCustomer, getWooCustomerById } from "./woo.js";
|
||||||
|
import { debug as dbg } from "../../shared/debug.js";
|
||||||
|
import { runTurnV3 } from "../../3-turn-engine/turnEngineV3.js";
|
||||||
|
import { safeNextState } from "../../3-turn-engine/fsm.js";
|
||||||
|
import { createOrder, updateOrder } from "../../4-woo-orders/wooOrders.js";
|
||||||
|
import { createPreference } from "../../6-mercadopago/mercadoPago.js";
|
||||||
|
|
||||||
|
function nowIso() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function newId(prefix = "run") {
|
||||||
|
return `${prefix}_${crypto.randomUUID()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePerf() {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
return { started_at, perf, mark, msBetween };
|
||||||
|
}
|
||||||
|
|
||||||
|
function logStage(enabled, stage, payload) {
|
||||||
|
if (!enabled) return;
|
||||||
|
console.log(`[pipeline] ${stage}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseAssistantMessages(messages) {
|
||||||
|
const out = [];
|
||||||
|
for (const m of messages || []) {
|
||||||
|
const last = out[out.length - 1];
|
||||||
|
if (last && last.role === "assistant" && m.role === "assistant") continue;
|
||||||
|
out.push(m);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureWooCustomerId({
|
||||||
|
tenantId,
|
||||||
|
chat_id,
|
||||||
|
displayName,
|
||||||
|
from,
|
||||||
|
externalCustomerId,
|
||||||
|
run_id,
|
||||||
|
}) {
|
||||||
|
let updatedId = externalCustomerId;
|
||||||
|
let error = null;
|
||||||
|
try {
|
||||||
|
if (updatedId) {
|
||||||
|
const found = await getWooCustomerById({ tenantId, id: updatedId });
|
||||||
|
if (!found) {
|
||||||
|
const phone = chat_id.replace(/@.+$/, "");
|
||||||
|
const name = displayName || from || phone;
|
||||||
|
const created = await createWooCustomer({ tenantId, wa_chat_id: chat_id, phone, name });
|
||||||
|
if (!created?.id) throw new Error("woo_customer_id_missing");
|
||||||
|
updatedId = await upsertExternalCustomerMap({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
wa_chat_id: chat_id,
|
||||||
|
external_customer_id: created?.id,
|
||||||
|
provider: "woo",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updatedId = await upsertExternalCustomerMap({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
wa_chat_id: chat_id,
|
||||||
|
external_customer_id: updatedId,
|
||||||
|
provider: "woo",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const phone = chat_id.replace(/@.+$/, "");
|
||||||
|
const name = displayName || from || phone;
|
||||||
|
const created = await createWooCustomer({ tenantId, wa_chat_id: chat_id, phone, name });
|
||||||
|
if (!created?.id) throw new Error("woo_customer_id_missing");
|
||||||
|
updatedId = await upsertExternalCustomerMap({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
wa_chat_id: chat_id,
|
||||||
|
external_customer_id: created?.id,
|
||||||
|
provider: "woo",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = {
|
||||||
|
message: String(e?.message || e),
|
||||||
|
status: e?.status || e?.cause?.status || null,
|
||||||
|
code: e?.body?.code || e?.cause?.body?.code || null,
|
||||||
|
run_id: run_id || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { external_customer_id: updatedId, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processMessage({
|
||||||
|
tenantId,
|
||||||
|
chat_id,
|
||||||
|
from,
|
||||||
|
text,
|
||||||
|
provider,
|
||||||
|
message_id,
|
||||||
|
displayName = null,
|
||||||
|
meta = null,
|
||||||
|
}) {
|
||||||
|
const { started_at, mark, msBetween } = makePerf();
|
||||||
|
|
||||||
|
await touchConversationState({ tenant_id: tenantId, wa_chat_id: chat_id });
|
||||||
|
|
||||||
|
mark("start");
|
||||||
|
const stageDebug = dbg.perf;
|
||||||
|
const prev = await getConversationState(tenantId, chat_id);
|
||||||
|
mark("after_getConversationState");
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
mark("after_getExternalCustomerIdByChat");
|
||||||
|
|
||||||
|
await insertMessage({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
wa_chat_id: chat_id,
|
||||||
|
provider,
|
||||||
|
message_id,
|
||||||
|
direction: "in",
|
||||||
|
text,
|
||||||
|
payload: { raw: { from, text, meta } },
|
||||||
|
run_id: null,
|
||||||
|
});
|
||||||
|
mark("after_insertMessage_in");
|
||||||
|
|
||||||
|
mark("before_getRecentMessagesForLLM_for_plan");
|
||||||
|
const history = await getRecentMessagesForLLM({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
wa_chat_id: chat_id,
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
const conversation_history = collapseAssistantMessages(history);
|
||||||
|
mark("after_getRecentMessagesForLLM_for_plan");
|
||||||
|
|
||||||
|
logStage(stageDebug, "history", { has_history: Array.isArray(conversation_history), state: prev_state });
|
||||||
|
|
||||||
|
let reducedContext = prev?.context && typeof prev?.context === "object" ? { ...prev.context } : {};
|
||||||
|
let decision;
|
||||||
|
let plan;
|
||||||
|
let llmMeta;
|
||||||
|
let tools = [];
|
||||||
|
|
||||||
|
mark("before_turn_v3");
|
||||||
|
const out = await runTurnV3({
|
||||||
|
tenantId,
|
||||||
|
chat_id,
|
||||||
|
text,
|
||||||
|
prev_state,
|
||||||
|
prev_context: reducedContext,
|
||||||
|
conversation_history,
|
||||||
|
});
|
||||||
|
plan = out.plan;
|
||||||
|
decision = out.decision || { context_patch: {}, actions: [], audit: {} };
|
||||||
|
llmMeta = { kind: "nlu_v3", audit: decision.audit || null };
|
||||||
|
tools = [];
|
||||||
|
mark("after_turn_v3");
|
||||||
|
|
||||||
|
const runStatus = llmMeta?.error ? "warn" : "ok";
|
||||||
|
const isSimulated = provider === "sim" || meta?.source === "sim";
|
||||||
|
|
||||||
|
const invariants = {
|
||||||
|
ok: true,
|
||||||
|
checks: [
|
||||||
|
{ name: "required_keys_present", ok: true },
|
||||||
|
{ name: "no_checkout_without_payment_link", ok: true },
|
||||||
|
{ name: "no_order_action_without_items", ok: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mark("before_insertRun");
|
||||||
|
|
||||||
|
// --- Ejecutar acciones determinísticas ---
|
||||||
|
let actionPatch = {};
|
||||||
|
if (Array.isArray(decision?.actions) && decision.actions.length) {
|
||||||
|
const newTools = [];
|
||||||
|
const actions = decision.actions;
|
||||||
|
|
||||||
|
const calcOrderTotal = (order) => {
|
||||||
|
const rawTotal = Number(order?.raw?.total);
|
||||||
|
if (Number.isFinite(rawTotal) && rawTotal > 0) return rawTotal;
|
||||||
|
const items = Array.isArray(order?.line_items) ? order.line_items : [];
|
||||||
|
let sum = 0;
|
||||||
|
for (const it of items) {
|
||||||
|
const t = Number(it?.total);
|
||||||
|
if (Number.isFinite(t)) sum += t;
|
||||||
|
}
|
||||||
|
return sum > 0 ? sum : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const needsWoo = actions.some((a) => a.type === "create_order" || a.type === "update_order");
|
||||||
|
if (needsWoo) {
|
||||||
|
const ensured = await ensureWooCustomerId({
|
||||||
|
tenantId,
|
||||||
|
chat_id,
|
||||||
|
displayName,
|
||||||
|
from,
|
||||||
|
externalCustomerId,
|
||||||
|
});
|
||||||
|
externalCustomerId = ensured.external_customer_id;
|
||||||
|
if (ensured.error) {
|
||||||
|
newTools.push({ type: "ensure_woo_customer", ok: false, error: ensured.error });
|
||||||
|
} else {
|
||||||
|
newTools.push({ type: "ensure_woo_customer", ok: true, external_customer_id: externalCustomerId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const act of actions) {
|
||||||
|
try {
|
||||||
|
if (act.type === "create_order") {
|
||||||
|
const order = await createOrder({
|
||||||
|
tenantId,
|
||||||
|
wooCustomerId: externalCustomerId,
|
||||||
|
basket: reducedContext?.order_basket || plan?.basket_resolved || { items: [] },
|
||||||
|
address: reducedContext?.delivery_address || reducedContext?.address || null,
|
||||||
|
run_id: null,
|
||||||
|
});
|
||||||
|
actionPatch.woo_order_id = order?.id || null;
|
||||||
|
actionPatch.order_total = calcOrderTotal(order);
|
||||||
|
newTools.push({ type: "create_order", ok: true, order_id: order?.id || null });
|
||||||
|
} else if (act.type === "update_order") {
|
||||||
|
const order = await updateOrder({
|
||||||
|
tenantId,
|
||||||
|
wooOrderId: reducedContext?.woo_order_id || prev?.last_order_id || null,
|
||||||
|
basket: reducedContext?.order_basket || plan?.basket_resolved || { items: [] },
|
||||||
|
address: reducedContext?.delivery_address || reducedContext?.address || null,
|
||||||
|
run_id: null,
|
||||||
|
});
|
||||||
|
actionPatch.woo_order_id = order?.id || null;
|
||||||
|
actionPatch.order_total = calcOrderTotal(order);
|
||||||
|
newTools.push({ type: "update_order", ok: true, order_id: order?.id || null });
|
||||||
|
} else if (act.type === "send_payment_link") {
|
||||||
|
const total = Number(actionPatch?.order_total || reducedContext?.order_total || 0) || null;
|
||||||
|
if (!total || total <= 0) {
|
||||||
|
throw new Error("order_total_missing");
|
||||||
|
}
|
||||||
|
const pref = await createPreference({
|
||||||
|
tenantId,
|
||||||
|
wooOrderId: actionPatch.woo_order_id || reducedContext?.woo_order_id || prev?.last_order_id,
|
||||||
|
amount: total || 0,
|
||||||
|
});
|
||||||
|
actionPatch.payment_link = pref?.init_point || null;
|
||||||
|
actionPatch.mp = {
|
||||||
|
preference_id: pref?.preference_id || null,
|
||||||
|
init_point: pref?.init_point || null,
|
||||||
|
};
|
||||||
|
newTools.push({ type: "send_payment_link", ok: true, preference_id: pref?.preference_id || null });
|
||||||
|
if (pref?.init_point) {
|
||||||
|
plan.reply = `Listo, acá tenés el link de pago: ${pref.init_point}\nAvisame cuando esté listo.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
newTools.push({ type: act.type, ok: false, error: String(e?.message || e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tools = newTools;
|
||||||
|
}
|
||||||
|
|
||||||
|
const run_id = await insertRun({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
wa_chat_id: chat_id,
|
||||||
|
message_id: `${provider}:${message_id}`,
|
||||||
|
prev_state,
|
||||||
|
user_text: text,
|
||||||
|
llm_output: { ...plan, _llm: llmMeta },
|
||||||
|
tools,
|
||||||
|
invariants,
|
||||||
|
final_reply: plan.reply,
|
||||||
|
status: runStatus,
|
||||||
|
latency_ms: null,
|
||||||
|
});
|
||||||
|
mark("after_insertRun");
|
||||||
|
|
||||||
|
const outMessageId = newId("out");
|
||||||
|
await insertMessage({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
wa_chat_id: chat_id,
|
||||||
|
provider,
|
||||||
|
message_id: outMessageId,
|
||||||
|
direction: "out",
|
||||||
|
text: plan.reply,
|
||||||
|
payload: { reply: plan.reply, railguard: { simulated: isSimulated, source: meta?.source || null } },
|
||||||
|
run_id,
|
||||||
|
});
|
||||||
|
mark("after_insertMessage_out");
|
||||||
|
|
||||||
|
if (llmMeta?.error) {
|
||||||
|
const errMsgId = newId("err");
|
||||||
|
await insertMessage({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
wa_chat_id: chat_id,
|
||||||
|
provider: "system",
|
||||||
|
message_id: errMsgId,
|
||||||
|
direction: "out",
|
||||||
|
text: `[ERROR] openai: ${llmMeta.error}`,
|
||||||
|
payload: { error: { source: "openai", ...llmMeta }, railguard: { simulated: isSimulated, source: meta?.source || null } },
|
||||||
|
run_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let wooCustomerError = null;
|
||||||
|
if (tools.some((t) => t.type === "ensure_woo_customer" && !t.ok)) {
|
||||||
|
wooCustomerError = tools.find((t) => t.type === "ensure_woo_customer" && !t.ok)?.error || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
...(reducedContext || {}),
|
||||||
|
...(decision?.context_patch || {}),
|
||||||
|
...(actionPatch || {}),
|
||||||
|
missing_fields: plan.missing_fields || [],
|
||||||
|
basket_resolved: (reducedContext?.order_basket?.items?.length ? reducedContext.order_basket : plan.basket_resolved) || { items: [] },
|
||||||
|
external_customer_id: externalCustomerId ?? prev?.context?.external_customer_id ?? null,
|
||||||
|
railguard: { simulated: isSimulated, source: meta?.source || null },
|
||||||
|
woo_customer_error: wooCustomerError,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextState = safeNextState(prev_state, context, { requested_checkout: plan.intent === "checkout" }).next_state;
|
||||||
|
plan.next_state = nextState;
|
||||||
|
|
||||||
|
const stateRow = await upsertConversationState({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
wa_chat_id: chat_id,
|
||||||
|
state: nextState,
|
||||||
|
last_intent: plan.intent,
|
||||||
|
last_order_id: null,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
mark("after_upsertConversationState");
|
||||||
|
|
||||||
|
sseSend("conversation.upsert", {
|
||||||
|
chat_id: stateRow.wa_chat_id,
|
||||||
|
from: stateRow.wa_chat_id.replace(/^sim:/, ""),
|
||||||
|
state: stateRow.state,
|
||||||
|
intent: stateRow.last_intent || "other",
|
||||||
|
status: runStatus,
|
||||||
|
last_activity: stateRow.updated_at,
|
||||||
|
last_run_id: run_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
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", {
|
||||||
|
run_id,
|
||||||
|
ts: nowIso(),
|
||||||
|
chat_id,
|
||||||
|
from,
|
||||||
|
status: runStatus,
|
||||||
|
prev_state,
|
||||||
|
input: { text },
|
||||||
|
llm_output: { ...plan, _llm: llmMeta },
|
||||||
|
tools,
|
||||||
|
invariants,
|
||||||
|
final_reply: plan.reply,
|
||||||
|
order_id: actionPatch.woo_order_id || null,
|
||||||
|
payment_link: actionPatch.payment_link || null,
|
||||||
|
latency_ms: end_to_end_ms,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[perf] processMessage", {
|
||||||
|
tenantId,
|
||||||
|
chat_id,
|
||||||
|
provider,
|
||||||
|
message_id,
|
||||||
|
run_id,
|
||||||
|
end_to_end_ms,
|
||||||
|
ms: {
|
||||||
|
db_state_ms: msBetween("start", "after_getConversationState"),
|
||||||
|
db_identity_ms: msBetween("after_getConversationState", "after_getExternalCustomerIdByChat"),
|
||||||
|
insert_in_ms: msBetween("after_getExternalCustomerIdByChat", "after_insertMessage_in"),
|
||||||
|
history_for_plan_ms: msBetween("after_insertMessage_in", "after_getRecentMessagesForLLM_for_plan"),
|
||||||
|
insert_run_ms: msBetween("before_insertRun", "after_insertRun"),
|
||||||
|
insert_out_ms: msBetween("after_insertRun", "after_insertMessage_out"),
|
||||||
|
upsert_state_ms: msBetween("after_insertMessage_out", "after_upsertConversationState"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { getDecryptedTenantEcommerceConfig } from "../db/repo.js";
|
import { getDecryptedTenantEcommerceConfig } from "../db/repo.js";
|
||||||
import { debug } from "./debug.js";
|
import { debug } from "../../shared/debug.js";
|
||||||
|
|
||||||
// --- Simple in-memory lock to serialize work per key (e.g. wa_chat_id) ---
|
// --- Simple in-memory lock to serialize work per key (e.g. wa_chat_id) ---
|
||||||
const locks = new Map();
|
const locks = new Map();
|
||||||
@@ -42,12 +42,8 @@ function isRetryableNetworkError(err) {
|
|||||||
const e2 = getDeep(err, ["cause", "cause"]);
|
const e2 = getDeep(err, ["cause", "cause"]);
|
||||||
|
|
||||||
const candidates = [e0, e1, e2].filter(Boolean);
|
const candidates = [e0, e1, e2].filter(Boolean);
|
||||||
const codes = new Set(
|
const codes = new Set(candidates.map((e) => e.code).filter(Boolean));
|
||||||
candidates.map((e) => e.code).filter(Boolean)
|
const names = new Set(candidates.map((e) => e.name).filter(Boolean));
|
||||||
);
|
|
||||||
const names = new Set(
|
|
||||||
candidates.map((e) => e.name).filter(Boolean)
|
|
||||||
);
|
|
||||||
const messages = candidates.map((e) => String(e.message || "")).join(" | ").toLowerCase();
|
const messages = candidates.map((e) => String(e.message || "")).join(" | ").toLowerCase();
|
||||||
|
|
||||||
const aborted =
|
const aborted =
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import OpenAI from "openai";
|
import OpenAI from "openai";
|
||||||
import { debug as dbg } from "./debug.js";
|
import { debug as dbg } from "../shared/debug.js";
|
||||||
import { searchProducts } from "./wooProducts.js";
|
import { searchSnapshotItems } from "../shared/wooSnapshot.js";
|
||||||
import {
|
import {
|
||||||
searchProductAliases,
|
searchProductAliases,
|
||||||
getProductEmbedding,
|
getProductEmbedding,
|
||||||
upsertProductEmbedding,
|
upsertProductEmbedding,
|
||||||
} from "../db/repo.js";
|
} from "../2-identity/db/repo.js";
|
||||||
|
|
||||||
function getOpenAiKey() {
|
function getOpenAiKey() {
|
||||||
return process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY || null;
|
return process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY || null;
|
||||||
@@ -148,13 +148,12 @@ export async function retrieveCandidates({
|
|||||||
}
|
}
|
||||||
audit.sources.aliases = aliases.length;
|
audit.sources.aliases = aliases.length;
|
||||||
|
|
||||||
const { items: wooItems, source: wooSource } = await searchProducts({
|
const { items: wooItems, source: wooSource } = await searchSnapshotItems({
|
||||||
tenantId,
|
tenantId,
|
||||||
q,
|
q,
|
||||||
limit: lim,
|
limit: lim,
|
||||||
forceWoo: true,
|
|
||||||
});
|
});
|
||||||
audit.sources.woo = { source: wooSource, count: wooItems?.length || 0 };
|
audit.sources.snapshot = { source: wooSource, count: wooItems?.length || 0 };
|
||||||
|
|
||||||
let candidates = (wooItems || []).map((c) => {
|
let candidates = (wooItems || []).map((c) => {
|
||||||
const lit = literalScore(q, c);
|
const lit = literalScore(q, c);
|
||||||
@@ -208,3 +207,4 @@ export async function retrieveCandidates({
|
|||||||
|
|
||||||
return { candidates: finalList, audit };
|
return { candidates: finalList, audit };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import OpenAI from "openai";
|
import OpenAI from "openai";
|
||||||
import { z } from "zod";
|
|
||||||
import Ajv from "ajv";
|
import Ajv from "ajv";
|
||||||
import { debug as dbg } from "./debug.js";
|
import { debug as dbg } from "../shared/debug.js";
|
||||||
|
|
||||||
let _client = null;
|
let _client = null;
|
||||||
let _clientKey = null;
|
let _clientKey = null;
|
||||||
@@ -23,67 +22,6 @@ function getClient() {
|
|||||||
return _client;
|
return _client;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NextStateSchema = z.enum([
|
|
||||||
"IDLE",
|
|
||||||
"BROWSING",
|
|
||||||
"BUILDING_ORDER",
|
|
||||||
"WAITING_ADDRESS",
|
|
||||||
"WAITING_PAYMENT",
|
|
||||||
"COMPLETED",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const IntentSchema = z.enum([
|
|
||||||
"ask_recommendation",
|
|
||||||
"ask_price",
|
|
||||||
"browse_products",
|
|
||||||
"create_order",
|
|
||||||
"add_item",
|
|
||||||
"remove_item",
|
|
||||||
"checkout",
|
|
||||||
"provide_address",
|
|
||||||
"confirm_payment",
|
|
||||||
"track_order",
|
|
||||||
"other",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const OrderActionSchema = z.enum(["none", "create", "update", "cancel", "checkout"]);
|
|
||||||
|
|
||||||
const BasketItemSchema = z.object({
|
|
||||||
product_id: z.number().int().nonnegative(),
|
|
||||||
variation_id: z.number().int().nonnegative().nullable(),
|
|
||||||
quantity: z.number().positive(),
|
|
||||||
unit: z.enum(["kg", "g", "unit"]),
|
|
||||||
label: z.string().min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
const PlanSchema = z
|
|
||||||
.object({
|
|
||||||
reply: z.string().min(1).max(350).catch(z.string().min(1)), // respetar guideline, sin romper si excede
|
|
||||||
next_state: NextStateSchema,
|
|
||||||
intent: IntentSchema,
|
|
||||||
missing_fields: z.array(z.string()).default([]),
|
|
||||||
order_action: OrderActionSchema.default("none"),
|
|
||||||
basket_resolved: z
|
|
||||||
.object({
|
|
||||||
items: z.array(BasketItemSchema).default([]),
|
|
||||||
})
|
|
||||||
.default({ items: [] }),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
const ExtractItemSchema = z.object({
|
|
||||||
label: z.string().min(1),
|
|
||||||
quantity: z.number().positive(),
|
|
||||||
unit: z.enum(["kg", "g", "unit"]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const ExtractSchema = z
|
|
||||||
.object({
|
|
||||||
intent: IntentSchema,
|
|
||||||
items: z.array(ExtractItemSchema).default([]),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
function extractJsonObject(text) {
|
function extractJsonObject(text) {
|
||||||
const s = String(text || "");
|
const s = String(text || "");
|
||||||
const i = s.indexOf("{");
|
const i = s.indexOf("{");
|
||||||
@@ -262,138 +200,4 @@ export async function llmNluV3({ input, model } = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Legacy llmPlan/llmExtract y NLU v2 removidos.
|
||||||
* Genera un "plan" de conversación (salida estructurada) usando OpenAI.
|
|
||||||
*
|
|
||||||
* - `promptSystem`: instrucciones del bot
|
|
||||||
* - `input`: { last_user_message, conversation_history, current_conversation_state, context }
|
|
||||||
*/
|
|
||||||
export async function llmPlan({ promptSystem, input, model } = {}) {
|
|
||||||
const system =
|
|
||||||
`${promptSystem}\n\n` +
|
|
||||||
"Respondé SOLO con un JSON válido (sin markdown). Respetá estrictamente el formato requerido.";
|
|
||||||
const { parsed, raw_text, model: chosenModel, usage } = await jsonCompletion({
|
|
||||||
system,
|
|
||||||
user: JSON.stringify(input ?? {}),
|
|
||||||
model,
|
|
||||||
});
|
|
||||||
|
|
||||||
const plan = PlanSchema.parse(parsed);
|
|
||||||
return {
|
|
||||||
plan,
|
|
||||||
raw_text,
|
|
||||||
model: chosenModel,
|
|
||||||
usage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Paso 1: extracción de intención + items mencionados (sin resolver IDs).
|
|
||||||
* Devuelve SOLO: intent + items[{label, quantity, unit}]
|
|
||||||
*/
|
|
||||||
export async function llmExtract({ input, model } = {}) {
|
|
||||||
const system =
|
|
||||||
"Extraé intención e items del mensaje del usuario.\n" +
|
|
||||||
"Respondé SOLO JSON válido (sin markdown) con keys EXACTAS:\n" +
|
|
||||||
`intent (one of: ${IntentSchema.options.join("|")}), items (array of {label, quantity, unit(kg|g|unit)}).\n` +
|
|
||||||
"Si no hay items claros, devolvé items: [].";
|
|
||||||
|
|
||||||
const { parsed, raw_text, model: chosenModel, usage } = await jsonCompletion({
|
|
||||||
system,
|
|
||||||
user: JSON.stringify(input ?? {}),
|
|
||||||
model,
|
|
||||||
});
|
|
||||||
|
|
||||||
const extracted = ExtractSchema.parse(parsed);
|
|
||||||
return { extracted, raw_text, model: chosenModel, usage };
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- NLU v2 (LLM-first) ---
|
|
||||||
|
|
||||||
const NluIntentV2Schema = z.enum([
|
|
||||||
"price_query",
|
|
||||||
"browse",
|
|
||||||
"add_to_cart",
|
|
||||||
"remove_from_cart",
|
|
||||||
"checkout",
|
|
||||||
"delivery_question",
|
|
||||||
"store_hours",
|
|
||||||
"greeting",
|
|
||||||
"other",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const NluSelectionSchema = z
|
|
||||||
.object({
|
|
||||||
type: z.enum(["index", "text", "sku"]),
|
|
||||||
value: z.string().min(1),
|
|
||||||
})
|
|
||||||
.nullable()
|
|
||||||
.default(null);
|
|
||||||
|
|
||||||
const NluEntitiesSchema = z
|
|
||||||
.object({
|
|
||||||
product_query: z.string().nullable().default(null),
|
|
||||||
quantity: z.number().nullable().default(null),
|
|
||||||
unit: z.enum(["kg", "g", "unidad", "docena"]).nullable().default(null),
|
|
||||||
selection: NluSelectionSchema,
|
|
||||||
attributes: z.array(z.string()).default([]),
|
|
||||||
preparation: z.array(z.string()).default([]),
|
|
||||||
budget: z.number().nullable().default(null),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
const NluNeedsSchema = z
|
|
||||||
.object({
|
|
||||||
catalog_lookup: z.boolean().default(false),
|
|
||||||
knowledge_lookup: z.boolean().default(false),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
const NluClarificationSchema = z
|
|
||||||
.object({
|
|
||||||
reason: z.enum([
|
|
||||||
"ambiguous_product",
|
|
||||||
"missing_quantity",
|
|
||||||
"missing_variant",
|
|
||||||
"missing_delivery_zone",
|
|
||||||
"none",
|
|
||||||
]),
|
|
||||||
question: z.string().nullable().default(null),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
const NluV2Schema = z
|
|
||||||
.object({
|
|
||||||
intent: NluIntentV2Schema,
|
|
||||||
confidence: z.number().min(0).max(1).default(0.5),
|
|
||||||
language: z.string().default("es-AR"),
|
|
||||||
entities: NluEntitiesSchema,
|
|
||||||
dialogue_act: z.enum(["answer", "ask_clarification", "confirm", "propose_options"]).default("answer"),
|
|
||||||
needs: NluNeedsSchema,
|
|
||||||
clarification: NluClarificationSchema,
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export async function llmNlu({ input, model } = {}) {
|
|
||||||
const system =
|
|
||||||
"Sos un servicio NLU para un asistente de carnicería en Argentina (es-AR).\n" +
|
|
||||||
"Tu tarea es EXTRAER intención, entidades y acto conversacional del mensaje del usuario.\n" +
|
|
||||||
"Respondé SOLO JSON válido (sin markdown) y con keys EXACTAS según el contrato.\n" +
|
|
||||||
"\n" +
|
|
||||||
"Reglas críticas:\n" +
|
|
||||||
"- Si el contexto incluye last_shown_options y el usuario responde con un número o 'el segundo/la cuarta', eso es selection {type:'index'}.\n" +
|
|
||||||
"- Si el usuario pone '2kg' o '500g' o '3 unidades' eso es quantity+unit.\n" +
|
|
||||||
"- Si el usuario pone solo un número y hay opciones mostradas, interpretalo como selection (no como cantidad).\n" +
|
|
||||||
"- Si el contexto indica pending_item (ya hay producto elegido) y NO hay opciones mostradas, y el usuario pone solo un número, interpretalo como quantity (con unit null o la que indique el usuario).\n" +
|
|
||||||
"- No inventes productos ni SKUs. product_query es lo que el usuario pidió (ej 'asado', 'tapa de asado wagyu').\n" +
|
|
||||||
"- needs.catalog_lookup debe ser true para intents: price_query, browse, add_to_cart (salvo que sea pura selección numérica sobre opciones ya mostradas).\n";
|
|
||||||
|
|
||||||
const { parsed, raw_text, model: chosenModel, usage } = await jsonCompletion({
|
|
||||||
system,
|
|
||||||
user: JSON.stringify(input ?? {}),
|
|
||||||
model,
|
|
||||||
});
|
|
||||||
|
|
||||||
const nlu = NluV2Schema.parse(parsed);
|
|
||||||
return { nlu, raw_text, model: chosenModel, usage };
|
|
||||||
}
|
|
||||||
@@ -582,3 +582,4 @@ export async function runTurnV3({
|
|||||||
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
|
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getDecryptedTenantEcommerceConfig, getWooProductCacheById } from "../db/repo.js";
|
import { getDecryptedTenantEcommerceConfig } from "../2-identity/db/repo.js";
|
||||||
import { debug as dbg } from "./debug.js";
|
import { debug as dbg } from "../shared/debug.js";
|
||||||
|
import { getSnapshotPriceByWooId } from "../shared/wooSnapshot.js";
|
||||||
|
|
||||||
// --- Simple in-memory lock to serialize work per key ---
|
// --- Simple in-memory lock to serialize work per key ---
|
||||||
const locks = new Map();
|
const locks = new Map();
|
||||||
@@ -100,8 +101,8 @@ function parsePrice(p) {
|
|||||||
|
|
||||||
async function getWooProductPrice({ tenantId, productId }) {
|
async function getWooProductPrice({ tenantId, productId }) {
|
||||||
if (!productId) return null;
|
if (!productId) return null;
|
||||||
const cached = await getWooProductCacheById({ tenant_id: tenantId, woo_product_id: productId });
|
const snap = await getSnapshotPriceByWooId({ tenantId, wooId: productId });
|
||||||
if (cached?.price != null) return Number(cached.price);
|
if (snap != null) return Number(snap);
|
||||||
const client = await getWooClient({ tenantId });
|
const client = await getWooClient({ tenantId });
|
||||||
const url = `${client.base}/products/${encodeURIComponent(productId)}`;
|
const url = `${client.base}/products/${encodeURIComponent(productId)}`;
|
||||||
const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
|
const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
|
||||||
@@ -234,3 +235,4 @@ export async function updateOrderStatus({ tenantId, wooOrderId, status }) {
|
|||||||
return { id: data?.id || wooOrderId, raw: data };
|
return { id: data?.id || wooOrderId, raw: data };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { fetchPayment, reconcilePayment, verifyWebhookSignature } from "../services/mercadoPago.js";
|
import { fetchPayment, reconcilePayment, verifyWebhookSignature } from "../mercadoPago.js";
|
||||||
|
|
||||||
export function makeMercadoPagoWebhook() {
|
export function makeMercadoPagoWebhook() {
|
||||||
return async function handleMercadoPagoWebhook(req, res) {
|
return async function handleMercadoPagoWebhook(req, res) {
|
||||||
@@ -39,3 +39,4 @@ export function makeMercadoPagoReturn() {
|
|||||||
res.status(200).send(`OK - ${status}`);
|
res.status(200).send(`OK - ${status}`);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { upsertMpPayment } from "../db/repo.js";
|
import { upsertMpPayment } from "../2-identity/db/repo.js";
|
||||||
import { updateOrderStatus } from "./wooOrders.js";
|
import { updateOrderStatus } from "../4-woo-orders/wooOrders.js";
|
||||||
|
|
||||||
function getAccessToken() {
|
function getAccessToken() {
|
||||||
return process.env.MP_ACCESS_TOKEN || null;
|
return process.env.MP_ACCESS_TOKEN || null;
|
||||||
@@ -175,3 +175,4 @@ export async function reconcilePayment({ tenantId, payment }) {
|
|||||||
|
|
||||||
return { payment: saved, woo_order_id: wooOrderId, tenant_id: resolvedTenantId };
|
return { payment: saved, woo_order_id: wooOrderId, tenant_id: resolvedTenantId };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7,3 +7,4 @@ export function createMercadoPagoRouter() {
|
|||||||
router.get("/return", makeMercadoPagoReturn());
|
router.get("/return", makeMercadoPagoReturn());
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,7 +13,6 @@ function envIsOff(v) {
|
|||||||
*
|
*
|
||||||
* - DEBUG_PERF: performance/latencias
|
* - DEBUG_PERF: performance/latencias
|
||||||
* - DEBUG_WOO_HTTP: requests/responses a Woo (status, timings, tamaño)
|
* - DEBUG_WOO_HTTP: requests/responses a Woo (status, timings, tamaño)
|
||||||
* - DEBUG_WOO_PRODUCTS: caching/queries de productos Woo
|
|
||||||
* - DEBUG_LLM: requests/responses a OpenAI
|
* - DEBUG_LLM: requests/responses a OpenAI
|
||||||
* - DEBUG_EVOLUTION: hook evolution + parse
|
* - DEBUG_EVOLUTION: hook evolution + parse
|
||||||
* - DEBUG_DB: queries/latencias DB (si se instrumenta)
|
* - DEBUG_DB: queries/latencias DB (si se instrumenta)
|
||||||
@@ -23,9 +22,6 @@ export const debug = {
|
|||||||
perf: envIsOn(process.env.DEBUG_PERF),
|
perf: envIsOn(process.env.DEBUG_PERF),
|
||||||
|
|
||||||
wooHttp: envIsOn(process.env.DEBUG_WOO_HTTP),
|
wooHttp: envIsOn(process.env.DEBUG_WOO_HTTP),
|
||||||
|
|
||||||
wooProducts: envIsOn(process.env.DEBUG_WOO_PRODUCTS),
|
|
||||||
|
|
||||||
llm: envIsOn(process.env.DEBUG_LLM),
|
llm: envIsOn(process.env.DEBUG_LLM),
|
||||||
|
|
||||||
evolution: envIsOn(process.env.DEBUG_EVOLUTION),
|
evolution: envIsOn(process.env.DEBUG_EVOLUTION),
|
||||||
@@ -39,4 +35,3 @@ export function debugOn(flagName) {
|
|||||||
return Boolean(debug?.[flagName]);
|
return Boolean(debug?.[flagName]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
253
src/modules/shared/wooSnapshot.js
Normal file
253
src/modules/shared/wooSnapshot.js
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import { pool } from "../2-identity/db/pool.js";
|
||||||
|
import { getDecryptedTenantEcommerceConfig } from "../2-identity/db/repo.js";
|
||||||
|
import { debug as dbg } from "./debug.js";
|
||||||
|
|
||||||
|
async function fetchWoo({ url, method = "GET", body = null, timeout = 20000, headers = {} }) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(new Error("timeout")), timeout);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
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;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWooClient({ tenantId }) {
|
||||||
|
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");
|
||||||
|
return {
|
||||||
|
base,
|
||||||
|
authHeader: { Authorization: `Basic ${auth}` },
|
||||||
|
timeout: Math.max(cfg.timeout_ms ?? 20000, 20000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePrice(p) {
|
||||||
|
if (p == null) return null;
|
||||||
|
const n = Number(String(p).replace(",", "."));
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAttributes(attrs) {
|
||||||
|
const out = {};
|
||||||
|
if (!Array.isArray(attrs)) return out;
|
||||||
|
for (const a of attrs) {
|
||||||
|
const name = String(a?.name || "").trim().toLowerCase();
|
||||||
|
if (!name) continue;
|
||||||
|
const options = Array.isArray(a?.options) ? a.options.map((v) => String(v).trim()).filter(Boolean) : [];
|
||||||
|
const value = a?.option ? [String(a.option).trim()] : options;
|
||||||
|
if (value.length) out[name] = value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWooProduct(p) {
|
||||||
|
return {
|
||||||
|
woo_id: p?.id,
|
||||||
|
type: p?.type || "simple",
|
||||||
|
parent_id: p?.parent_id || null,
|
||||||
|
name: p?.name || "",
|
||||||
|
slug: p?.slug || null,
|
||||||
|
status: p?.status || null,
|
||||||
|
catalog_visibility: p?.catalog_visibility || null,
|
||||||
|
price_regular: parsePrice(p?.regular_price),
|
||||||
|
price_sale: parsePrice(p?.sale_price),
|
||||||
|
price_current: parsePrice(p?.price),
|
||||||
|
stock_status: p?.stock_status || null,
|
||||||
|
stock_qty: p?.stock_quantity != null ? Number(p.stock_quantity) : null,
|
||||||
|
backorders: p?.backorders || null,
|
||||||
|
categories: Array.isArray(p?.categories) ? p.categories.map((c) => c?.name || c?.slug).filter(Boolean) : [],
|
||||||
|
tags: Array.isArray(p?.tags) ? p.tags.map((c) => c?.name || c?.slug).filter(Boolean) : [],
|
||||||
|
attributes_normalized: normalizeAttributes(p?.attributes || []),
|
||||||
|
date_modified: p?.date_modified || null,
|
||||||
|
raw: p,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotRowToItem(row) {
|
||||||
|
const categories = Array.isArray(row?.categories) ? row.categories : [];
|
||||||
|
const attributes = row?.attributes_normalized && typeof row.attributes_normalized === "object" ? row.attributes_normalized : {};
|
||||||
|
return {
|
||||||
|
woo_product_id: row?.woo_id,
|
||||||
|
name: row?.name || "",
|
||||||
|
sku: row?.slug || null,
|
||||||
|
price: row?.price_current != null ? Number(row.price_current) : null,
|
||||||
|
currency: null,
|
||||||
|
type: row?.type || null,
|
||||||
|
categories: categories.map((c) => ({ id: null, name: String(c), slug: String(c) })),
|
||||||
|
attributes: Object.entries(attributes).map(([name, options]) => ({
|
||||||
|
name,
|
||||||
|
options: Array.isArray(options) ? options : [String(options)],
|
||||||
|
})),
|
||||||
|
raw_price: {
|
||||||
|
price: row?.price_current ?? null,
|
||||||
|
regular_price: row?.price_regular ?? null,
|
||||||
|
sale_price: row?.price_sale ?? null,
|
||||||
|
price_html: null,
|
||||||
|
},
|
||||||
|
source: "snapshot",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function insertSnapshotRun({ tenantId, source, total }) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`insert into woo_snapshot_runs (tenant_id, source, total_items) values ($1, $2, $3) returning id`,
|
||||||
|
[tenantId, source, total || 0]
|
||||||
|
);
|
||||||
|
return rows[0]?.id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchSnapshotItems({ tenantId, q = "", limit = 12 }) {
|
||||||
|
const lim = Math.max(1, Math.min(50, parseInt(limit, 10) || 12));
|
||||||
|
const query = String(q || "").trim();
|
||||||
|
if (!query) return { items: [], source: "snapshot" };
|
||||||
|
const like = `%${query}%`;
|
||||||
|
const sql = `
|
||||||
|
select *
|
||||||
|
from sellable_items
|
||||||
|
where tenant_id=$1
|
||||||
|
and (name ilike $2 or coalesce(slug,'') ilike $2)
|
||||||
|
order by updated_at desc
|
||||||
|
limit $3
|
||||||
|
`;
|
||||||
|
const { rows } = await pool.query(sql, [tenantId, like, lim]);
|
||||||
|
return { items: rows.map(snapshotRowToItem), source: "snapshot" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSnapshotPriceByWooId({ tenantId, wooId }) {
|
||||||
|
if (!wooId) return null;
|
||||||
|
const sql = `
|
||||||
|
select price_current
|
||||||
|
from woo_products_snapshot
|
||||||
|
where tenant_id=$1 and woo_id=$2
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
const { rows } = await pool.query(sql, [tenantId, wooId]);
|
||||||
|
const price = rows[0]?.price_current;
|
||||||
|
return price == null ? null : Number(price);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertSnapshotItems({ tenantId, items, runId = null }) {
|
||||||
|
const rows = Array.isArray(items) ? items : [];
|
||||||
|
for (const item of rows) {
|
||||||
|
const q = `
|
||||||
|
insert into woo_products_snapshot
|
||||||
|
(tenant_id, woo_id, type, parent_id, name, slug, status, catalog_visibility,
|
||||||
|
price_regular, price_sale, price_current, stock_status, stock_qty, backorders,
|
||||||
|
categories, tags, attributes_normalized, date_modified, run_id, raw, updated_at)
|
||||||
|
values
|
||||||
|
($1,$2,$3,$4,$5,$6,$7,$8,
|
||||||
|
$9,$10,$11,$12,$13,$14,
|
||||||
|
$15::jsonb,$16::jsonb,$17::jsonb,$18,$19,$20::jsonb,now())
|
||||||
|
on conflict (tenant_id, woo_id)
|
||||||
|
do update set
|
||||||
|
type = excluded.type,
|
||||||
|
parent_id = excluded.parent_id,
|
||||||
|
name = excluded.name,
|
||||||
|
slug = excluded.slug,
|
||||||
|
status = excluded.status,
|
||||||
|
catalog_visibility = excluded.catalog_visibility,
|
||||||
|
price_regular = excluded.price_regular,
|
||||||
|
price_sale = excluded.price_sale,
|
||||||
|
price_current = excluded.price_current,
|
||||||
|
stock_status = excluded.stock_status,
|
||||||
|
stock_qty = excluded.stock_qty,
|
||||||
|
backorders = excluded.backorders,
|
||||||
|
categories = excluded.categories,
|
||||||
|
tags = excluded.tags,
|
||||||
|
attributes_normalized = excluded.attributes_normalized,
|
||||||
|
date_modified = excluded.date_modified,
|
||||||
|
run_id = excluded.run_id,
|
||||||
|
raw = excluded.raw,
|
||||||
|
updated_at = now()
|
||||||
|
`;
|
||||||
|
await pool.query(q, [
|
||||||
|
tenantId,
|
||||||
|
item.woo_id,
|
||||||
|
item.type,
|
||||||
|
item.parent_id,
|
||||||
|
item.name,
|
||||||
|
item.slug,
|
||||||
|
item.status,
|
||||||
|
item.catalog_visibility,
|
||||||
|
item.price_regular,
|
||||||
|
item.price_sale,
|
||||||
|
item.price_current,
|
||||||
|
item.stock_status,
|
||||||
|
item.stock_qty,
|
||||||
|
item.backorders,
|
||||||
|
JSON.stringify(item.categories || []),
|
||||||
|
JSON.stringify(item.tags || []),
|
||||||
|
JSON.stringify(item.attributes_normalized || {}),
|
||||||
|
item.date_modified,
|
||||||
|
runId,
|
||||||
|
JSON.stringify(item.raw || {}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMissingItems({ tenantId, runId }) {
|
||||||
|
if (!runId) return;
|
||||||
|
await pool.query(
|
||||||
|
`delete from woo_products_snapshot where tenant_id=$1 and coalesce(run_id,0) <> $2`,
|
||||||
|
[tenantId, runId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshProductByWooId({ tenantId, wooId, parentId = null }) {
|
||||||
|
const client = await getWooClient({ tenantId });
|
||||||
|
let url = `${client.base}/products/${encodeURIComponent(wooId)}`;
|
||||||
|
if (parentId) {
|
||||||
|
url = `${client.base}/products/${encodeURIComponent(parentId)}/variations/${encodeURIComponent(wooId)}`;
|
||||||
|
}
|
||||||
|
const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
|
||||||
|
if (dbg.wooHttp) console.log("[wooSnapshot] refresh", { wooId, parentId, type: data?.type });
|
||||||
|
const normalized = normalizeWooProduct(data);
|
||||||
|
await upsertSnapshotItems({ tenantId, items: [normalized], runId: null });
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,415 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
import { llmNlu } from "./openai.js";
|
|
||||||
import { searchProducts } from "./wooProducts.js";
|
|
||||||
|
|
||||||
// --- Types / Contracts (runtime-validated where it matters) ---
|
|
||||||
|
|
||||||
const TurnActionSchema = z.object({
|
|
||||||
type: z.enum(["show_options", "quote_price", "add_to_cart", "ask_clarification"]),
|
|
||||||
payload: z.record(z.any()).default({}),
|
|
||||||
});
|
|
||||||
|
|
||||||
function normalizeUnit(unit) {
|
|
||||||
const u = unit == null ? null : String(unit).toLowerCase();
|
|
||||||
if (!u) return null;
|
|
||||||
if (u === "kg" || u === "kilo" || u === "kilos") return "kg";
|
|
||||||
if (u === "g" || u === "gramo" || u === "gramos") return "g";
|
|
||||||
if (u === "unidad" || u === "unidades" || u === "unit") return "unit";
|
|
||||||
if (u === "docena" || u === "docenas") return "docena";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickProductQuery({ nlu, prevContext }) {
|
|
||||||
const q = nlu?.entities?.product_query || null;
|
|
||||||
if (q && String(q).trim()) return String(q).trim();
|
|
||||||
const last = prevContext?.slots?.product_label || prevContext?.pending_item?.name || null;
|
|
||||||
return last && String(last).trim() ? String(last).trim() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapIntentToLegacy(intent) {
|
|
||||||
switch (intent) {
|
|
||||||
case "price_query":
|
|
||||||
return "ask_price";
|
|
||||||
case "browse":
|
|
||||||
return "browse_products";
|
|
||||||
case "add_to_cart":
|
|
||||||
return "add_item";
|
|
||||||
case "remove_from_cart":
|
|
||||||
return "remove_item";
|
|
||||||
case "checkout":
|
|
||||||
return "checkout";
|
|
||||||
case "greeting":
|
|
||||||
return "other";
|
|
||||||
default:
|
|
||||||
return "other";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatARS(n) {
|
|
||||||
const num = Number(n);
|
|
||||||
if (!Number.isFinite(num)) return String(n);
|
|
||||||
// simple ARS formatting (miles con punto)
|
|
||||||
return Math.round(num).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferUnitHintByName(name) {
|
|
||||||
const n = String(name || "").toLowerCase();
|
|
||||||
// Embutidos / parrilleros: puede ser kg o unidad
|
|
||||||
if (/\b(chorizo|chorizos|morcilla|morcillas|salchicha|salchichas|parrillera|parrilleras)\b/.test(n)) {
|
|
||||||
return { defaultUnit: "unit", ask: "¿Los querés por kg o por unidad?" };
|
|
||||||
}
|
|
||||||
// Bebidas: unidad
|
|
||||||
if (/\b(vino|vinos|cerveza|cervezas|gaseosa|gaseosas|whisky|ron|gin|vodka|fernet)\b/.test(n)) {
|
|
||||||
return { defaultUnit: "unit", ask: "¿Cuántas unidades querés?" };
|
|
||||||
}
|
|
||||||
// Carnes: kg (no preguntar “por kilo”, preguntar cantidad)
|
|
||||||
return { defaultUnit: "kg", ask: "¿Cuántos kilos querés?" };
|
|
||||||
}
|
|
||||||
|
|
||||||
function unitAskFor(unit) {
|
|
||||||
if (unit === "g") return "¿Cuántos gramos querés?";
|
|
||||||
if (unit === "unit") return "¿Cuántas unidades querés?";
|
|
||||||
return "¿Cuántos kilos querés?";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatQty({ quantity, unit }) {
|
|
||||||
const q = Number(quantity);
|
|
||||||
if (!Number.isFinite(q) || q <= 0) return String(quantity);
|
|
||||||
if (unit === "g") return `${q}g`;
|
|
||||||
if (unit === "unit") return `${q}u`;
|
|
||||||
return `${q}kg`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildOptionsList(candidates, { baseIdx = 1, pageSize = 9 } = {}) {
|
|
||||||
const slice = candidates.slice(0, Math.max(1, Math.min(20, pageSize)));
|
|
||||||
const options = slice.map((c, i) => ({
|
|
||||||
idx: baseIdx + i,
|
|
||||||
woo_product_id: c.woo_product_id,
|
|
||||||
name: c.name,
|
|
||||||
price: c.price ?? null,
|
|
||||||
}));
|
|
||||||
const list = options.map((o) => `- ${o.idx}) ${o.name}`).join("\n");
|
|
||||||
return { options, text: `¿Cuál de estos querés?\n${list}\n\nRespondé con el número.` };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseIndexSelection(text) {
|
|
||||||
const t = String(text || "").toLowerCase();
|
|
||||||
const m = /\b(\d{1,2})\b/.exec(t);
|
|
||||||
if (!m) return null;
|
|
||||||
const n = parseInt(m[1], 10);
|
|
||||||
return Number.isFinite(n) ? n : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Turn Engine v2: “LLM-first NLU, deterministic core”
|
|
||||||
*
|
|
||||||
* Devuelve un objeto compatible con el pipeline actual:
|
|
||||||
* { plan, llmMeta, decision }
|
|
||||||
* - plan: { reply, next_state, intent, missing_fields, order_action, basket_resolved }
|
|
||||||
* - decision.context_patch: para que pipeline lo persista
|
|
||||||
*/
|
|
||||||
export async function runTurnV2({
|
|
||||||
tenantId,
|
|
||||||
chat_id,
|
|
||||||
text,
|
|
||||||
prev_state,
|
|
||||||
prev_context,
|
|
||||||
conversation_history,
|
|
||||||
} = {}) {
|
|
||||||
const prev = prev_context && typeof prev_context === "object" ? prev_context : {};
|
|
||||||
|
|
||||||
const last_shown_options = Array.isArray(prev?.pending_clarification?.options)
|
|
||||||
? prev.pending_clarification.options.map((o) => ({ idx: o.idx, type: o.type, name: o.name, woo_product_id: o.woo_product_id || null }))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const nluInput = {
|
|
||||||
last_user_message: text,
|
|
||||||
conversation_state: prev_state || "IDLE",
|
|
||||||
memory_summary: Array.isArray(conversation_history)
|
|
||||||
? conversation_history
|
|
||||||
.slice(-6)
|
|
||||||
.map((m) => `${m.role === "user" ? "U" : "A"}:${String(m.content || "").slice(0, 120)}`)
|
|
||||||
.join(" | ")
|
|
||||||
: "",
|
|
||||||
pending_context: {
|
|
||||||
pending_clarification: Boolean(prev?.pending_clarification?.candidates?.length),
|
|
||||||
pending_item: prev?.pending_item?.name || null,
|
|
||||||
},
|
|
||||||
last_shown_options,
|
|
||||||
locale: "es-AR",
|
|
||||||
customer_profile: prev?.customer_profile || null,
|
|
||||||
feature_flags: { turn_engine: "v2" },
|
|
||||||
};
|
|
||||||
|
|
||||||
const { nlu, raw_text, model, usage } = await llmNlu({ input: nluInput });
|
|
||||||
const llmMeta = { model, usage, raw_text, kind: "nlu_v2" };
|
|
||||||
|
|
||||||
const actions = [];
|
|
||||||
const context_patch = {};
|
|
||||||
|
|
||||||
// --- AWAITING_QUANTITY / pending_item ---
|
|
||||||
// Si ya hay un producto elegido, el siguiente turno suele ser cantidad/unidad.
|
|
||||||
if (prev?.pending_item?.product_id && !prev?.pending_clarification?.candidates?.length) {
|
|
||||||
const q0 = nlu?.entities?.quantity;
|
|
||||||
const u0 = normalizeUnit(nlu?.entities?.unit);
|
|
||||||
// Permitir "por kg"/"por unidad" sin número: actualizamos default_unit y preguntamos cantidad.
|
|
||||||
if (!q0 && u0) {
|
|
||||||
context_patch.pending_item = { ...(prev.pending_item || {}), default_unit: u0 === "g" ? "g" : u0 === "unit" ? "unit" : "kg" };
|
|
||||||
const plan = {
|
|
||||||
reply: unitAskFor(context_patch.pending_item.default_unit),
|
|
||||||
next_state: "BUILDING_ORDER",
|
|
||||||
intent: "add_item",
|
|
||||||
missing_fields: ["quantity"],
|
|
||||||
order_action: "none",
|
|
||||||
basket_resolved: { items: [] },
|
|
||||||
};
|
|
||||||
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, stage: "pending_item_set_unit" } };
|
|
||||||
return { plan, llmMeta, decision };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Número solo (ej "4") -> quantity, con unit por default_unit del pending_item.
|
|
||||||
if (q0 && Number(q0) > 0) {
|
|
||||||
const defaultUnit = prev.pending_item.default_unit || "kg";
|
|
||||||
const unit = u0 === "g" ? "g" : u0 === "unit" || u0 === "docena" ? "unit" : defaultUnit === "g" ? "g" : defaultUnit === "unit" ? "unit" : "kg";
|
|
||||||
const qty = u0 === "docena" ? Number(q0) * 12 : Number(q0);
|
|
||||||
const it = {
|
|
||||||
product_id: Number(prev.pending_item.product_id),
|
|
||||||
variation_id: prev.pending_item.variation_id ?? null,
|
|
||||||
quantity: qty,
|
|
||||||
unit,
|
|
||||||
label: prev.pending_item.name || "ese producto",
|
|
||||||
};
|
|
||||||
context_patch.pending_item = null;
|
|
||||||
context_patch.order_basket = { items: [...(prev?.order_basket?.items || []), it] };
|
|
||||||
actions.push({ type: "add_to_cart", payload: { product_id: it.product_id, quantity: it.quantity, unit: it.unit } });
|
|
||||||
const plan = {
|
|
||||||
reply: `Perfecto, anoto ${formatQty({ quantity: it.quantity, unit: it.unit })} de ${it.label}. ¿Algo más?`,
|
|
||||||
next_state: "BUILDING_ORDER",
|
|
||||||
intent: "add_item",
|
|
||||||
missing_fields: [],
|
|
||||||
order_action: "none",
|
|
||||||
basket_resolved: { items: [it] },
|
|
||||||
};
|
|
||||||
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, stage: "pending_item_qty" } };
|
|
||||||
return { plan, llmMeta, decision };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si no entendimos cantidad, re-preguntamos con la unidad correcta.
|
|
||||||
const fallbackUnit = prev.pending_item.default_unit || inferUnitHintByName(prev.pending_item.name || "").defaultUnit;
|
|
||||||
const plan = {
|
|
||||||
reply: unitAskFor(fallbackUnit === "unit" ? "unit" : fallbackUnit === "g" ? "g" : "kg"),
|
|
||||||
next_state: "BUILDING_ORDER",
|
|
||||||
intent: "add_item",
|
|
||||||
missing_fields: ["quantity"],
|
|
||||||
order_action: "none",
|
|
||||||
basket_resolved: { items: [] },
|
|
||||||
};
|
|
||||||
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, stage: "pending_item_reask" } };
|
|
||||||
return { plan, llmMeta, decision };
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Deterministic overrides from state/pending ---
|
|
||||||
// Si hay pending_clarification, una respuesta numérica debe interpretarse como selección.
|
|
||||||
const selectionIdx =
|
|
||||||
(nlu?.entities?.selection?.type === "index" ? parseInt(String(nlu.entities.selection.value), 10) : null) ??
|
|
||||||
(prev?.pending_clarification?.candidates?.length ? parseIndexSelection(text) : null);
|
|
||||||
|
|
||||||
// Si hay selectionIdx y options vigentes, elegimos producto.
|
|
||||||
let chosen = null;
|
|
||||||
if (selectionIdx && Array.isArray(prev?.pending_clarification?.options)) {
|
|
||||||
const opt = prev.pending_clarification.options.find((o) => o.idx === selectionIdx && o.type === "product");
|
|
||||||
if (opt) {
|
|
||||||
chosen = prev.pending_clarification.candidates?.find((c) => c.woo_product_id === opt.woo_product_id) || null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Retrieval (catálogo) ---
|
|
||||||
const productQuery = pickProductQuery({ nlu, prevContext: prev });
|
|
||||||
let candidates = [];
|
|
||||||
if (nlu?.needs?.catalog_lookup || ["price_query", "browse", "add_to_cart"].includes(nlu.intent)) {
|
|
||||||
if (productQuery) {
|
|
||||||
const { items } = await searchProducts({ tenantId, q: productQuery, limit: 12 });
|
|
||||||
candidates = Array.isArray(items) ? items : [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si no venía de pending_clarification pero NLU pidió browse, y tenemos candidatos, mostramos lista.
|
|
||||||
if (!chosen && (nlu.intent === "browse" || (nlu.intent === "price_query" && candidates.length > 1))) {
|
|
||||||
const { options, text: listText } = buildOptionsList(candidates, { baseIdx: 1, pageSize: 9 });
|
|
||||||
actions.push({ type: "show_options", payload: { count: options.length } });
|
|
||||||
context_patch.pending_clarification = {
|
|
||||||
candidates: candidates.map((c) => ({
|
|
||||||
woo_product_id: c.woo_product_id,
|
|
||||||
name: c.name,
|
|
||||||
price: c.price ?? null,
|
|
||||||
categories: c.categories || [],
|
|
||||||
attributes: c.attributes || [],
|
|
||||||
})),
|
|
||||||
options: options.map((o) => ({ idx: o.idx, type: "product", woo_product_id: o.woo_product_id, name: o.name })),
|
|
||||||
candidate_offset: 0,
|
|
||||||
page_size: 9,
|
|
||||||
base_idx: 1,
|
|
||||||
has_more: false,
|
|
||||||
next_candidate_offset: options.length,
|
|
||||||
next_base_idx: options.length + 1,
|
|
||||||
};
|
|
||||||
// clave: al entrar en selección por lista, anulamos pending_item viejo
|
|
||||||
context_patch.pending_item = null;
|
|
||||||
const plan = {
|
|
||||||
reply: listText,
|
|
||||||
next_state: "BROWSING",
|
|
||||||
intent: "browse_products",
|
|
||||||
missing_fields: ["product_selection"],
|
|
||||||
order_action: "none",
|
|
||||||
basket_resolved: { items: [] },
|
|
||||||
};
|
|
||||||
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, productQuery, candidates_count: candidates.length } };
|
|
||||||
return { plan, llmMeta, decision };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si hubo elección por índice (chosen), pasamos a AWAITING_QUANTITY (representado como BUILDING_ORDER).
|
|
||||||
if (chosen) {
|
|
||||||
actions.push({ type: "ask_clarification", payload: { reason: "missing_quantity" } });
|
|
||||||
const unitHint = inferUnitHintByName(chosen.name);
|
|
||||||
context_patch.pending_clarification = null;
|
|
||||||
context_patch.pending_item = {
|
|
||||||
product_id: Number(chosen.woo_product_id),
|
|
||||||
variation_id: null,
|
|
||||||
name: chosen.name,
|
|
||||||
price: chosen.price ?? null,
|
|
||||||
categories: chosen.categories || [],
|
|
||||||
attributes: chosen.attributes || [],
|
|
||||||
default_unit: unitHint.defaultUnit === "unit" ? "unit" : "kg",
|
|
||||||
};
|
|
||||||
|
|
||||||
// si el mismo turno ya trajo cantidad, agregamos al carrito directo
|
|
||||||
const q = nlu?.entities?.quantity;
|
|
||||||
const u = normalizeUnit(nlu?.entities?.unit);
|
|
||||||
if (q && Number(q) > 0) {
|
|
||||||
const unit = u === "g" ? "g" : u === "unit" || u === "docena" ? "unit" : "kg";
|
|
||||||
const qty = u === "docena" ? Number(q) * 12 : Number(q);
|
|
||||||
const it = {
|
|
||||||
product_id: Number(chosen.woo_product_id),
|
|
||||||
variation_id: null,
|
|
||||||
quantity: qty,
|
|
||||||
unit,
|
|
||||||
label: chosen.name,
|
|
||||||
};
|
|
||||||
context_patch.pending_item = null;
|
|
||||||
context_patch.order_basket = { items: [...(prev?.order_basket?.items || []), it] };
|
|
||||||
actions.push({ type: "add_to_cart", payload: { product_id: it.product_id, quantity: it.quantity, unit: it.unit } });
|
|
||||||
const plan = {
|
|
||||||
reply: `Perfecto, anoto ${unit === "kg" ? `${qty}kg` : `${qty}u`} de ${chosen.name}. ¿Algo más?`,
|
|
||||||
next_state: "BUILDING_ORDER",
|
|
||||||
intent: "add_item",
|
|
||||||
missing_fields: [],
|
|
||||||
order_action: "none",
|
|
||||||
basket_resolved: { items: [it] },
|
|
||||||
};
|
|
||||||
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, chosen: { id: chosen.woo_product_id, name: chosen.name } } };
|
|
||||||
return { plan, llmMeta, decision };
|
|
||||||
}
|
|
||||||
|
|
||||||
const plan = {
|
|
||||||
reply: unitHint.ask,
|
|
||||||
next_state: "BUILDING_ORDER",
|
|
||||||
intent: "add_item",
|
|
||||||
missing_fields: ["quantity"],
|
|
||||||
order_action: "none",
|
|
||||||
basket_resolved: { items: [] },
|
|
||||||
};
|
|
||||||
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, chosen: { id: chosen.woo_product_id, name: chosen.name } } };
|
|
||||||
return { plan, llmMeta, decision };
|
|
||||||
}
|
|
||||||
|
|
||||||
// price_query: si hay 1 candidato claro => cotizar
|
|
||||||
if (nlu.intent === "price_query" && candidates.length === 1) {
|
|
||||||
const c = candidates[0];
|
|
||||||
actions.push({ type: "quote_price", payload: { product_id: c.woo_product_id } });
|
|
||||||
const unitHint = inferUnitHintByName(c.name);
|
|
||||||
const price = c.price;
|
|
||||||
const per = unitHint.defaultUnit === "unit" ? "por unidad" : "el kilo";
|
|
||||||
const reply =
|
|
||||||
price != null
|
|
||||||
? `${c.name} está $${formatARS(price)} ${per}. ${unitHint.ask}`
|
|
||||||
: `Dale, lo reviso y te confirmo el precio en un momento. ${unitHint.ask}`;
|
|
||||||
// en price, dejamos pending_item para que el siguiente turno sea cantidad
|
|
||||||
context_patch.pending_item = {
|
|
||||||
product_id: Number(c.woo_product_id),
|
|
||||||
variation_id: null,
|
|
||||||
name: c.name,
|
|
||||||
price: c.price ?? null,
|
|
||||||
categories: c.categories || [],
|
|
||||||
attributes: c.attributes || [],
|
|
||||||
default_unit: unitHint.defaultUnit === "unit" ? "unit" : "kg",
|
|
||||||
};
|
|
||||||
context_patch.pending_clarification = null;
|
|
||||||
const plan = {
|
|
||||||
reply,
|
|
||||||
next_state: "BROWSING",
|
|
||||||
intent: "ask_price",
|
|
||||||
missing_fields: ["quantity"],
|
|
||||||
order_action: "none",
|
|
||||||
basket_resolved: { items: [] },
|
|
||||||
};
|
|
||||||
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, candidates_count: candidates.length } };
|
|
||||||
return { plan, llmMeta, decision };
|
|
||||||
}
|
|
||||||
|
|
||||||
// add_to_cart sin candidates: preguntamos producto
|
|
||||||
if (nlu.intent === "add_to_cart" && !productQuery) {
|
|
||||||
actions.push({ type: "ask_clarification", payload: { reason: "missing_product" } });
|
|
||||||
const plan = {
|
|
||||||
reply: "Dale. ¿Qué producto querés agregar?",
|
|
||||||
next_state: prev_state || "IDLE",
|
|
||||||
intent: "add_item",
|
|
||||||
missing_fields: ["product_query"],
|
|
||||||
order_action: "none",
|
|
||||||
basket_resolved: { items: [] },
|
|
||||||
};
|
|
||||||
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu } };
|
|
||||||
return { plan, llmMeta, decision };
|
|
||||||
}
|
|
||||||
|
|
||||||
// add_to_cart con candidates y cantidad: si hay 1 candidato claro, agregamos directo.
|
|
||||||
if (nlu.intent === "add_to_cart" && candidates.length === 1 && nlu?.entities?.quantity && Number(nlu.entities.quantity) > 0) {
|
|
||||||
const c = candidates[0];
|
|
||||||
const u = normalizeUnit(nlu?.entities?.unit);
|
|
||||||
const unitHint = inferUnitHintByName(c.name);
|
|
||||||
const unit = u === "g" ? "g" : u === "unit" || u === "docena" ? "unit" : unitHint.defaultUnit === "unit" ? "unit" : "kg";
|
|
||||||
const qty = u === "docena" ? Number(nlu.entities.quantity) * 12 : Number(nlu.entities.quantity);
|
|
||||||
const it = { product_id: Number(c.woo_product_id), variation_id: null, quantity: qty, unit, label: c.name };
|
|
||||||
context_patch.order_basket = { items: [...(prev?.order_basket?.items || []), it] };
|
|
||||||
actions.push({ type: "add_to_cart", payload: { product_id: it.product_id, quantity: it.quantity, unit: it.unit } });
|
|
||||||
const plan = {
|
|
||||||
reply: `Perfecto, anoto ${formatQty({ quantity: it.quantity, unit: it.unit })} de ${c.name}. ¿Algo más?`,
|
|
||||||
next_state: "BUILDING_ORDER",
|
|
||||||
intent: "add_item",
|
|
||||||
missing_fields: [],
|
|
||||||
order_action: "none",
|
|
||||||
basket_resolved: { items: [it] },
|
|
||||||
};
|
|
||||||
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, stage: "add_to_cart_direct" } };
|
|
||||||
return { plan, llmMeta, decision };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: respuesta segura y corta, sin inventar.
|
|
||||||
const legacyIntent = mapIntentToLegacy(nlu.intent);
|
|
||||||
const plan = {
|
|
||||||
reply: nlu?.clarification?.question || "Dale. ¿Qué necesitás exactamente?",
|
|
||||||
next_state: prev_state || "IDLE",
|
|
||||||
intent: legacyIntent,
|
|
||||||
missing_fields: [],
|
|
||||||
order_action: "none",
|
|
||||||
basket_resolved: { items: [] },
|
|
||||||
};
|
|
||||||
const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, productQuery, candidates_count: candidates.length } };
|
|
||||||
// validate actions shape (best-effort, no throw)
|
|
||||||
try {
|
|
||||||
decision.actions = Array.isArray(actions) ? actions.map((a) => TurnActionSchema.parse(a)) : [];
|
|
||||||
} catch {
|
|
||||||
decision.actions = [];
|
|
||||||
}
|
|
||||||
return { plan, llmMeta, decision };
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
import { getDecryptedTenantEcommerceConfig, getWooProductCacheById, searchWooProductCache, upsertWooProductCache } from "../db/repo.js";
|
|
||||||
import { debug as dbg } from "./debug.js";
|
|
||||||
|
|
||||||
async function fetchWoo({ url, method = "GET", body = null, timeout = 8000, 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,
|
|
||||||
});
|
|
||||||
const text = await res.text();
|
|
||||||
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;
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getWooClient({ tenantId }) {
|
|
||||||
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");
|
|
||||||
|
|
||||||
return {
|
|
||||||
base,
|
|
||||||
authHeader: { Authorization: `Basic ${auth}` },
|
|
||||||
timeout: Math.max(cfg.timeout_ms ?? 8000, 2000),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePrice(p) {
|
|
||||||
if (p == null) return null;
|
|
||||||
const n = Number(String(p).replace(",", "."));
|
|
||||||
return Number.isFinite(n) ? n : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isStale(refreshed_at, maxAgeMs) {
|
|
||||||
if (!refreshed_at) return true;
|
|
||||||
const t = new Date(refreshed_at).getTime();
|
|
||||||
if (!Number.isFinite(t)) return true;
|
|
||||||
return Date.now() - t > maxAgeMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeWooProduct(p) {
|
|
||||||
return {
|
|
||||||
woo_product_id: p?.id,
|
|
||||||
name: p?.name || "",
|
|
||||||
sku: p?.sku || null,
|
|
||||||
price: parsePrice(p?.price ?? p?.regular_price ?? p?.sale_price),
|
|
||||||
currency: null,
|
|
||||||
type: p?.type || null, // simple | variable | grouped | external
|
|
||||||
categories: Array.isArray(p?.categories)
|
|
||||||
? p.categories.map((c) => ({
|
|
||||||
id: c?.id ?? null,
|
|
||||||
name: c?.name ?? null,
|
|
||||||
slug: c?.slug ?? null,
|
|
||||||
}))
|
|
||||||
: [],
|
|
||||||
attributes: Array.isArray(p?.attributes)
|
|
||||||
? p.attributes.map((a) => ({
|
|
||||||
name: a?.name || null,
|
|
||||||
options: Array.isArray(a?.options) ? a.options.slice(0, 20) : [],
|
|
||||||
}))
|
|
||||||
: [],
|
|
||||||
raw_price: {
|
|
||||||
price: p?.price ?? null,
|
|
||||||
regular_price: p?.regular_price ?? null,
|
|
||||||
sale_price: p?.sale_price ?? null,
|
|
||||||
price_html: p?.price_html ?? null,
|
|
||||||
},
|
|
||||||
payload: p,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function searchProducts({
|
|
||||||
tenantId,
|
|
||||||
q,
|
|
||||||
limit = 10,
|
|
||||||
maxAgeMs = 24 * 60 * 60 * 1000,
|
|
||||||
forceWoo = false,
|
|
||||||
}) {
|
|
||||||
const debug = dbg.wooProducts;
|
|
||||||
const lim = Math.max(1, Math.min(50, parseInt(limit, 10) || 10));
|
|
||||||
const query = String(q || "").trim();
|
|
||||||
if (!query) return { items: [], source: "none" };
|
|
||||||
|
|
||||||
// 1) Cache en Postgres
|
|
||||||
const cached = await searchWooProductCache({ tenant_id: tenantId, q: query, limit: lim });
|
|
||||||
|
|
||||||
// 2) Si no hay suficiente (o force), buscamos en Woo y cacheamos
|
|
||||||
// Nota: si el cache tiene 3 items pero pedimos 12, igual necesitamos ir a Woo para no “recortar” catálogo.
|
|
||||||
const needWooSearch = forceWoo || cached.length < lim;
|
|
||||||
const client = await getWooClient({ tenantId });
|
|
||||||
|
|
||||||
let wooItems = [];
|
|
||||||
if (needWooSearch) {
|
|
||||||
const url = `${client.base}/products?search=${encodeURIComponent(query)}&per_page=${lim}`;
|
|
||||||
const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
|
|
||||||
wooItems = Array.isArray(data) ? data : [];
|
|
||||||
|
|
||||||
if (debug) {
|
|
||||||
console.log("[wooProducts] search", {
|
|
||||||
tenantId,
|
|
||||||
query,
|
|
||||||
count: wooItems.length,
|
|
||||||
sample: wooItems.slice(0, 5).map((p) => ({
|
|
||||||
id: p?.id,
|
|
||||||
name: p?.name,
|
|
||||||
sku: p?.sku,
|
|
||||||
price: p?.price,
|
|
||||||
regular_price: p?.regular_price,
|
|
||||||
sale_price: p?.sale_price,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const p of wooItems) {
|
|
||||||
const n = normalizeWooProduct(p);
|
|
||||||
if (!n.woo_product_id || !n.name) continue;
|
|
||||||
await upsertWooProductCache({
|
|
||||||
tenant_id: tenantId,
|
|
||||||
woo_product_id: n.woo_product_id,
|
|
||||||
name: n.name,
|
|
||||||
sku: n.sku,
|
|
||||||
price: n.price,
|
|
||||||
currency: n.currency,
|
|
||||||
payload: n.payload,
|
|
||||||
refreshed_at: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Si tenemos cache pero está stale, refrescamos precio contra Woo (por ID) y actualizamos si cambió.
|
|
||||||
// Nota: lo hacemos solo para los items que vamos a devolver (lim), para no demorar demasiado.
|
|
||||||
const toReturn = cached.slice(0, lim);
|
|
||||||
for (const c of toReturn) {
|
|
||||||
if (!isStale(c.refreshed_at, maxAgeMs)) continue;
|
|
||||||
try {
|
|
||||||
const url = `${client.base}/products/${encodeURIComponent(c.woo_product_id)}`;
|
|
||||||
const p = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
|
|
||||||
if (debug) {
|
|
||||||
console.log("[wooProducts] refresh", {
|
|
||||||
tenantId,
|
|
||||||
woo_product_id: c.woo_product_id,
|
|
||||||
name: p?.name,
|
|
||||||
sku: p?.sku,
|
|
||||||
price: p?.price,
|
|
||||||
regular_price: p?.regular_price,
|
|
||||||
sale_price: p?.sale_price,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const n = normalizeWooProduct(p);
|
|
||||||
if (!n.woo_product_id || !n.name) continue;
|
|
||||||
// Si cambió el precio (o faltaba), actualizamos.
|
|
||||||
const prevPrice = c.price == null ? null : Number(c.price);
|
|
||||||
const nextPrice = n.price;
|
|
||||||
const changed = prevPrice !== nextPrice;
|
|
||||||
|
|
||||||
await upsertWooProductCache({
|
|
||||||
tenant_id: tenantId,
|
|
||||||
woo_product_id: n.woo_product_id,
|
|
||||||
name: n.name,
|
|
||||||
sku: n.sku,
|
|
||||||
price: n.price,
|
|
||||||
currency: n.currency,
|
|
||||||
payload: n.payload,
|
|
||||||
refreshed_at: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (changed) {
|
|
||||||
// mantener coherencia de respuesta: refrescar item desde DB
|
|
||||||
const refreshed = await getWooProductCacheById({ tenant_id: tenantId, woo_product_id: n.woo_product_id });
|
|
||||||
if (refreshed) Object.assign(c, refreshed);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// si Woo falla, devolvemos cache (mejor que nada)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) Respuesta “unificada” (preferimos cache, pero si hicimos Woo search devolvemos esos)
|
|
||||||
const finalItems = needWooSearch
|
|
||||||
? wooItems
|
|
||||||
.map(normalizeWooProduct)
|
|
||||||
.filter((p) => p.woo_product_id && p.name)
|
|
||||||
.slice(0, lim)
|
|
||||||
.map((p) => ({
|
|
||||||
woo_product_id: p.woo_product_id,
|
|
||||||
name: p.name,
|
|
||||||
sku: p.sku,
|
|
||||||
price: p.price,
|
|
||||||
currency: p.currency,
|
|
||||||
type: p.type,
|
|
||||||
categories: p.categories,
|
|
||||||
attributes: p.attributes,
|
|
||||||
raw_price: p.raw_price,
|
|
||||||
source: "woo",
|
|
||||||
}))
|
|
||||||
: toReturn.map((c) => ({
|
|
||||||
woo_product_id: c.woo_product_id,
|
|
||||||
name: c.name,
|
|
||||||
sku: c.sku,
|
|
||||||
price: c.price == null ? null : Number(c.price),
|
|
||||||
currency: c.currency,
|
|
||||||
refreshed_at: c.refreshed_at,
|
|
||||||
type: c?.payload?.type || null,
|
|
||||||
categories: Array.isArray(c?.payload?.categories)
|
|
||||||
? c.payload.categories.map((cat) => ({
|
|
||||||
id: cat?.id ?? null,
|
|
||||||
name: cat?.name ?? null,
|
|
||||||
slug: cat?.slug ?? null,
|
|
||||||
}))
|
|
||||||
: [],
|
|
||||||
attributes: Array.isArray(c?.payload?.attributes)
|
|
||||||
? c.payload.attributes.map((a) => ({
|
|
||||||
name: a?.name || null,
|
|
||||||
options: Array.isArray(a?.options) ? a.options.slice(0, 20) : [],
|
|
||||||
}))
|
|
||||||
: [],
|
|
||||||
raw_price: {
|
|
||||||
price: c?.payload?.price ?? null,
|
|
||||||
regular_price: c?.payload?.regular_price ?? null,
|
|
||||||
sale_price: c?.payload?.sale_price ?? null,
|
|
||||||
price_html: c?.payload?.price_html ?? null,
|
|
||||||
},
|
|
||||||
source: isStale(c.refreshed_at, maxAgeMs) ? "cache_stale" : "cache",
|
|
||||||
}));
|
|
||||||
|
|
||||||
return { items: finalItems, source: needWooSearch ? "woo" : "cache" };
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user