From 23c3d444905eb9d74b4c0b126b69be73287980db Mon Sep 17 00:00:00 2001
From: Lucas Tettamanti <757326+lkzwieder@users.noreply.github.com>
Date: Sun, 18 Jan 2026 19:00:49 -0300
Subject: [PATCH] audit and sync
---
db/migrations/20260119130000_audit_log.sql | 18 ++
public/components/products-crud.js | 24 ++-
public/lib/api.js | 5 +
src/modules/0-ui/controllers/products.js | 16 +-
src/modules/0-ui/db/repo.js | 60 +++++++
src/modules/0-ui/handlers/products.js | 72 +++++++-
src/modules/1-intake/routes/simulator.js | 3 +-
.../2-identity/controllers/wooWebhooks.js | 69 +++++++-
src/modules/shared/wooSnapshot.js | 156 ++++++++++++++++++
9 files changed, 404 insertions(+), 19 deletions(-)
create mode 100644 db/migrations/20260119130000_audit_log.sql
diff --git a/db/migrations/20260119130000_audit_log.sql b/db/migrations/20260119130000_audit_log.sql
new file mode 100644
index 0000000..c9c631d
--- /dev/null
+++ b/db/migrations/20260119130000_audit_log.sql
@@ -0,0 +1,18 @@
+-- migrate:up
+
+create table if not exists audit_log (
+ id bigserial primary key,
+ tenant_id uuid not null references tenants(id) on delete cascade,
+ entity_type text not null, -- 'product' | 'user' | 'order'
+ entity_id text not null, -- woo_product_id, wa_chat_id, etc.
+ action text not null, -- 'create' | 'update' | 'delete' | 'sync_from_woo' | 'push_to_woo'
+ changes jsonb, -- { field: { old, new } }
+ actor text not null default 'system', -- 'system' | 'webhook' | 'ui'
+ created_at timestamptz not null default now()
+);
+
+create index if not exists audit_log_entity_idx on audit_log(tenant_id, entity_type, entity_id);
+create index if not exists audit_log_created_idx on audit_log(tenant_id, created_at desc);
+
+-- migrate:down
+drop table if exists audit_log;
diff --git a/public/components/products-crud.js b/public/components/products-crud.js
index e791f72..cd7eac7 100644
--- a/public/components/products-crud.js
+++ b/public/components/products-crud.js
@@ -73,7 +73,7 @@ class ProductsCrud extends HTMLElement {
Cargando productos...
@@ -141,19 +141,37 @@ class ProductsCrud extends HTMLElement {
}
async syncFromWoo() {
+ // Mostrar confirmación antes de sincronizar
+ const confirmed = confirm(
+ "⚠️ Resincronización de emergencia\n\n" +
+ "Esto reimportará TODOS los productos desde WooCommerce y sobrescribirá los datos locales.\n\n" +
+ "Usar solo si:\n" +
+ "• La plataforma estuvo caída mientras se hacían cambios en Woo\n" +
+ "• Los webhooks no funcionaron correctamente\n" +
+ "• Necesitás una sincronización completa\n\n" +
+ "¿Continuar?"
+ );
+
+ if (!confirmed) return;
+
const btn = this.shadowRoot.getElementById("syncBtn");
btn.disabled = true;
btn.textContent = "Sincronizando...";
try {
- await api.syncProducts();
+ const result = await api.syncFromWoo();
+ if (result.ok) {
+ alert(`Sincronización completada: ${result.synced} productos importados`);
+ } else {
+ alert("Error: " + (result.error || "Error desconocido"));
+ }
await this.load();
} catch (e) {
console.error("Error syncing products:", e);
alert("Error sincronizando: " + (e.message || e));
} finally {
btn.disabled = false;
- btn.textContent = "Sync Woo";
+ btn.textContent = "Resincronizar";
}
}
diff --git a/public/lib/api.js b/public/lib/api.js
index 4ee2a7f..2c69e67 100644
--- a/public/lib/api.js
+++ b/public/lib/api.js
@@ -179,4 +179,9 @@ export const api = {
body: JSON.stringify({ rules }),
}).then(r => r.json());
},
+
+ // Sync de emergencia desde WooCommerce
+ async syncFromWoo() {
+ return fetch("/products/sync-from-woo", { method: "POST" }).then(r => r.json());
+ },
};
diff --git a/src/modules/0-ui/controllers/products.js b/src/modules/0-ui/controllers/products.js
index 69e030c..be7f08d 100644
--- a/src/modules/0-ui/controllers/products.js
+++ b/src/modules/0-ui/controllers/products.js
@@ -1,4 +1,4 @@
-import { handleSearchProducts, handleListProducts, handleGetProduct, handleSyncProducts, handleUpdateProductUnit, handleBulkUpdateProductUnit, handleUpdateProduct } from "../handlers/products.js";
+import { handleSearchProducts, handleListProducts, handleGetProduct, handleSyncProducts, handleSyncFromWoo, handleUpdateProductUnit, handleBulkUpdateProductUnit, handleUpdateProduct } from "../handlers/products.js";
export const makeSearchProducts = (tenantIdOrFn) => async (req, res) => {
try {
@@ -54,6 +54,20 @@ export const makeSyncProducts = (tenantIdOrFn) => async (req, res) => {
}
};
+/**
+ * Sync manual de emergencia: reimporta todos los productos de WooCommerce
+ */
+export const makeSyncFromWoo = (tenantIdOrFn) => async (req, res) => {
+ try {
+ const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
+ const result = await handleSyncFromWoo({ tenantId });
+ res.json(result);
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ ok: false, error: "internal_error", message: err.message });
+ }
+};
+
export const makeUpdateProductUnit = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
diff --git a/src/modules/0-ui/db/repo.js b/src/modules/0-ui/db/repo.js
index bf1d2cd..87b5180 100644
--- a/src/modules/0-ui/db/repo.js
+++ b/src/modules/0-ui/db/repo.js
@@ -624,3 +624,63 @@ export async function countQtyRulesByProduct({ tenantId }) {
const { rows } = await pool.query(sql, [tenantId]);
return rows;
}
+
+// ─────────────────────────────────────────────────────────────
+// Audit Log
+// ─────────────────────────────────────────────────────────────
+
+/**
+ * Inserta un registro en el audit log
+ * @param {Object} params
+ * @param {string} params.tenantId - UUID del tenant
+ * @param {string} params.entityType - 'product' | 'user' | 'order'
+ * @param {string} params.entityId - ID de la entidad (woo_product_id, wa_chat_id, etc)
+ * @param {string} params.action - 'create' | 'update' | 'delete' | 'sync_from_woo' | 'push_to_woo'
+ * @param {Object} params.changes - { field: { old, new } }
+ * @param {string} params.actor - 'system' | 'webhook' | 'ui'
+ */
+export async function insertAuditLog({ tenantId, entityType, entityId, action, changes, actor = 'system' }) {
+ const sql = `
+ insert into audit_log (tenant_id, entity_type, entity_id, action, changes, actor)
+ values ($1, $2, $3, $4, $5::jsonb, $6)
+ returning id, created_at
+ `;
+ const { rows } = await pool.query(sql, [
+ tenantId,
+ entityType,
+ String(entityId),
+ action,
+ changes ? JSON.stringify(changes) : null,
+ actor
+ ]);
+ return rows[0] || null;
+}
+
+/**
+ * Obtiene el historial de cambios de una entidad
+ */
+export async function getAuditLog({ tenantId, entityType, entityId, limit = 50 }) {
+ const sql = `
+ select id, entity_type, entity_id, action, changes, actor, created_at
+ from audit_log
+ where tenant_id = $1
+ and ($2::text is null or entity_type = $2)
+ and ($3::text is null or entity_id = $3)
+ order by created_at desc
+ limit $4
+ `;
+ const { rows } = await pool.query(sql, [tenantId, entityType || null, entityId || null, limit]);
+ return rows;
+}
+
+/**
+ * Obtiene el historial de cambios recientes de productos
+ */
+export async function getProductAuditLog({ tenantId, wooProductId, limit = 20 }) {
+ return getAuditLog({
+ tenantId,
+ entityType: 'product',
+ entityId: wooProductId ? String(wooProductId) : null,
+ limit
+ });
+}
diff --git a/src/modules/0-ui/handlers/products.js b/src/modules/0-ui/handlers/products.js
index 13d97d4..8087cb8 100644
--- a/src/modules/0-ui/handlers/products.js
+++ b/src/modules/0-ui/handlers/products.js
@@ -1,5 +1,5 @@
-import { searchSnapshotItems } from "../../shared/wooSnapshot.js";
-import { listProducts, getProductByWooId, updateProductSellUnit, bulkUpdateProductSellUnit, updateProduct } from "../db/repo.js";
+import { searchSnapshotItems, syncFromWoo, pushProductToWoo } from "../../shared/wooSnapshot.js";
+import { listProducts, getProductByWooId, updateProductSellUnit, bulkUpdateProductSellUnit, updateProduct, insertAuditLog } from "../db/repo.js";
export async function handleSearchProducts({ tenantId, q = "", limit = "10", forceWoo = "0" }) {
const { items, source } = await searchSnapshotItems({
@@ -20,9 +20,34 @@ export async function handleGetProduct({ tenantId, wooProductId }) {
}
export async function handleSyncProducts({ tenantId }) {
- // This is a placeholder - actual sync would fetch from Woo API
- // For now, just return success
- return { ok: true, message: "Sync triggered (use import script for full sync)" };
+ // Placeholder legacy - redirigir a syncFromWoo
+ return handleSyncFromWoo({ tenantId });
+}
+
+/**
+ * Sincroniza todos los productos desde WooCommerce (solo para emergencias)
+ */
+export async function handleSyncFromWoo({ tenantId }) {
+ console.log("[products] handleSyncFromWoo starting...");
+
+ try {
+ const result = await syncFromWoo({ tenantId });
+
+ // Registrar en audit_log
+ await insertAuditLog({
+ tenantId,
+ entityType: 'product',
+ entityId: 'all',
+ action: 'sync_from_woo',
+ changes: { synced_count: result.synced, ms: result.ms },
+ actor: 'ui'
+ });
+
+ return { ok: true, synced: result.synced, ms: result.ms };
+ } catch (err) {
+ console.error("[products] handleSyncFromWoo error:", err);
+ return { ok: false, error: err.message };
+ }
}
export async function handleUpdateProductUnit({ tenantId, wooProductId, sell_unit }) {
@@ -35,8 +60,43 @@ export async function handleBulkUpdateProductUnit({ tenantId, wooProductIds, sel
return { ok: true, updated_count: wooProductIds.length, sell_unit };
}
+/**
+ * Actualiza un producto localmente y automáticamente lo pushea a WooCommerce
+ */
export async function handleUpdateProduct({ tenantId, wooProductId, sell_unit, categories }) {
+ // 1. Guardar cambios localmente
await updateProduct({ tenantId, wooProductId, sell_unit, categories });
- return { ok: true, woo_product_id: wooProductId, sell_unit, categories };
+
+ // 2. Auto-push a WooCommerce (best effort - no falla si Woo no responde)
+ let wooPushResult = null;
+ try {
+ wooPushResult = await pushProductToWoo({ tenantId, wooProductId, categories, sellUnit: sell_unit });
+ console.log("[products] handleUpdateProduct: pushed to Woo", { wooProductId, updated: wooPushResult?.updated });
+ } catch (err) {
+ console.error("[products] handleUpdateProduct: Woo push failed (continuing anyway)", err.message);
+ // No lanzamos el error - el guardado local fue exitoso
+ }
+
+ // 3. Registrar en audit_log
+ await insertAuditLog({
+ tenantId,
+ entityType: 'product',
+ entityId: String(wooProductId),
+ action: 'update',
+ changes: {
+ sell_unit: { new: sell_unit },
+ categories: { new: categories },
+ woo_synced: wooPushResult?.updated ?? false
+ },
+ actor: 'ui'
+ });
+
+ return {
+ ok: true,
+ woo_product_id: wooProductId,
+ sell_unit,
+ categories,
+ woo_synced: wooPushResult?.updated ?? false
+ };
}
diff --git a/src/modules/1-intake/routes/simulator.js b/src/modules/1-intake/routes/simulator.js
index 7ab29dc..21049d2 100644
--- a/src/modules/1-intake/routes/simulator.js
+++ b/src/modules/1-intake/routes/simulator.js
@@ -6,7 +6,7 @@ import { makeListRuns, makeGetRunById } from "../../0-ui/controllers/runs.js";
import { makeSimSend } from "../controllers/sim.js";
import { makeGetConversationState } from "../../0-ui/controllers/conversationState.js";
import { makeListMessages } from "../../0-ui/controllers/messages.js";
-import { makeSearchProducts, makeListProducts, makeGetProduct, makeSyncProducts, makeUpdateProductUnit, makeBulkUpdateProductUnit, makeUpdateProduct } from "../../0-ui/controllers/products.js";
+import { makeSearchProducts, makeListProducts, makeGetProduct, makeSyncProducts, makeSyncFromWoo, makeUpdateProductUnit, makeBulkUpdateProductUnit, makeUpdateProduct } from "../../0-ui/controllers/products.js";
import { makeListAliases, makeCreateAlias, makeUpdateAlias, makeDeleteAlias } from "../../0-ui/controllers/aliases.js";
import { makeListRecommendations, makeGetRecommendation, makeCreateRecommendation, makeUpdateRecommendation, makeDeleteRecommendation } from "../../0-ui/controllers/recommendations.js";
import { makeListProductQtyRules, makeGetProductQtyRules, makeSaveProductQtyRules } from "../../0-ui/controllers/quantities.js";
@@ -56,6 +56,7 @@ export function createSimulatorRouter({ tenantId }) {
router.get("/products/search", makeSearchProducts(getTenantId));
router.patch("/products/bulk/unit", makeBulkUpdateProductUnit(getTenantId));
router.post("/products/sync", makeSyncProducts(getTenantId));
+ router.post("/products/sync-from-woo", makeSyncFromWoo(getTenantId));
router.get("/products/:id", makeGetProduct(getTenantId));
router.patch("/products/:id/unit", makeUpdateProductUnit(getTenantId));
router.patch("/products/:id", makeUpdateProduct(getTenantId));
diff --git a/src/modules/2-identity/controllers/wooWebhooks.js b/src/modules/2-identity/controllers/wooWebhooks.js
index 4bfb565..11972b4 100644
--- a/src/modules/2-identity/controllers/wooWebhooks.js
+++ b/src/modules/2-identity/controllers/wooWebhooks.js
@@ -1,5 +1,6 @@
import { refreshProductByWooId } from "../../shared/wooSnapshot.js";
import { getTenantByKey } from "../db/repo.js";
+import { insertAuditLog } from "../../0-ui/db/repo.js";
function unauthorized(res) {
res.setHeader("WWW-Authenticate", 'Basic realm="woo-webhook"');
@@ -18,11 +19,25 @@ function checkBasicAuth(req) {
return { ok: false, reason: "invalid_creds" };
}
-function parseWooId(payload) {
+function parseWooPayload(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 };
+ const action = payload?.action || null; // 'created' | 'updated' | 'deleted'
+
+ // Extraer campos relevantes para audit
+ const name = payload?.name || null;
+ const price = payload?.price || null;
+ const stockStatus = payload?.stock_status || null;
+ const stockQty = payload?.stock_quantity ?? null;
+
+ return {
+ id: id ? Number(id) : null,
+ parentId: parentId ? Number(parentId) : null,
+ resource,
+ action,
+ changes: { name, price, stockStatus, stockQty }
+ };
}
export function makeWooProductWebhook() {
@@ -30,7 +45,7 @@ export function makeWooProductWebhook() {
const auth = checkBasicAuth(req);
if (!auth.ok) return unauthorized(res);
- const { id, parentId, resource } = parseWooId(req.body || {});
+ const { id, parentId, resource, action, changes } = parseWooPayload(req.body || {});
if (!id) return res.status(400).json({ ok: false, error: "missing_id" });
// Determinar tenant por query ?tenant_key=...
@@ -42,17 +57,55 @@ export function makeWooProductWebhook() {
const parentForVariation =
resource && String(resource).includes("variation") ? parentId || null : null;
- const updated = await refreshProductByWooId({
- tenantId: tenant.id,
- wooId: id,
- parentId: parentForVariation,
- });
+ // Determinar acción para audit
+ let auditAction = 'update';
+ if (action === 'created' || resource?.includes('created')) {
+ auditAction = 'create';
+ } else if (action === 'deleted' || resource?.includes('deleted')) {
+ auditAction = 'delete';
+ }
+
+ let updated = null;
+
+ // Si es delete, no podemos refresh (el producto ya no existe en Woo)
+ if (auditAction !== 'delete') {
+ try {
+ updated = await refreshProductByWooId({
+ tenantId: tenant.id,
+ wooId: id,
+ parentId: parentForVariation,
+ });
+ } catch (err) {
+ console.error("[wooWebhook] Error refreshing product:", err.message);
+ // Si falla el refresh (ej: producto eliminado), registramos igual en audit
+ }
+ }
+
+ // Registrar en audit_log
+ try {
+ await insertAuditLog({
+ tenantId: tenant.id,
+ entityType: 'product',
+ entityId: String(id),
+ action: auditAction,
+ changes: {
+ ...changes,
+ resource,
+ woo_action: action,
+ refreshed: updated != null
+ },
+ actor: 'webhook'
+ });
+ } catch (err) {
+ console.error("[wooWebhook] Error inserting audit log:", err.message);
+ }
return res.status(200).json({
ok: true,
woo_id: updated?.woo_id || id,
type: updated?.type || null,
parent_id: updated?.parent_id || null,
+ action: auditAction,
});
};
}
diff --git a/src/modules/shared/wooSnapshot.js b/src/modules/shared/wooSnapshot.js
index f8206cd..41a0da0 100644
--- a/src/modules/shared/wooSnapshot.js
+++ b/src/modules/shared/wooSnapshot.js
@@ -276,3 +276,159 @@ export async function refreshProductByWooId({ tenantId, wooId, parentId = null }
return normalized;
}
+// ─────────────────────────────────────────────────────────────
+// Sincronización completa con WooCommerce
+// ─────────────────────────────────────────────────────────────
+
+/**
+ * Obtiene todos los productos de WooCommerce usando paginación
+ */
+export async function fetchAllWooProducts({ tenantId }) {
+ const client = await getWooClient({ tenantId });
+ const allProducts = [];
+ let page = 1;
+ const perPage = 100;
+
+ console.log("[wooSnapshot] fetchAllWooProducts starting...");
+
+ while (true) {
+ const url = `${client.base}/products?per_page=${perPage}&page=${page}&status=any`;
+ const data = await fetchWoo({
+ url,
+ method: "GET",
+ timeout: client.timeout * 2, // Más tiempo para listados grandes
+ headers: client.authHeader
+ });
+
+ if (!Array.isArray(data) || data.length === 0) {
+ break;
+ }
+
+ allProducts.push(...data);
+ console.log(`[wooSnapshot] fetchAllWooProducts page ${page}: ${data.length} products (total: ${allProducts.length})`);
+
+ if (data.length < perPage) {
+ break; // Última página
+ }
+
+ page++;
+ }
+
+ console.log(`[wooSnapshot] fetchAllWooProducts completed: ${allProducts.length} products`);
+ return allProducts;
+}
+
+/**
+ * Sincroniza todos los productos desde WooCommerce (reemplaza snapshot local)
+ */
+export async function syncFromWoo({ tenantId }) {
+ console.log("[wooSnapshot] syncFromWoo starting...");
+ const t0 = Date.now();
+
+ // 1. Obtener todos los productos de Woo
+ const wooProducts = await fetchAllWooProducts({ tenantId });
+
+ if (wooProducts.length === 0) {
+ console.log("[wooSnapshot] syncFromWoo: no products found in Woo");
+ return { ok: true, synced: 0, ms: Date.now() - t0 };
+ }
+
+ // 2. Crear run para tracking
+ const runId = await insertSnapshotRun({ tenantId, source: "sync_from_woo", total: wooProducts.length });
+
+ // 3. Normalizar e insertar productos
+ const normalized = wooProducts.map(normalizeWooProduct);
+ await upsertSnapshotItems({ tenantId, items: normalized, runId });
+
+ // 4. Eliminar productos que ya no existen en Woo
+ await deleteMissingItems({ tenantId, runId });
+
+ const ms = Date.now() - t0;
+ console.log(`[wooSnapshot] syncFromWoo completed: ${wooProducts.length} products in ${ms}ms`);
+
+ return { ok: true, synced: wooProducts.length, ms };
+}
+
+/**
+ * Pushea cambios de un producto a WooCommerce
+ */
+export async function pushProductToWoo({ tenantId, wooProductId, categories, sellUnit }) {
+ const client = await getWooClient({ tenantId });
+ const url = `${client.base}/products/${encodeURIComponent(wooProductId)}`;
+
+ // Construir payload de actualización
+ const updatePayload = {};
+
+ // Actualizar categorías si se proporcionaron
+ if (categories && Array.isArray(categories) && categories.length > 0) {
+ // Primero obtener las categorías existentes en Woo para mapear nombres a IDs
+ const existingCats = await fetchWooCategoriesByNames({ tenantId, names: categories });
+ if (existingCats.length > 0) {
+ updatePayload.categories = existingCats.map(c => ({ id: c.id }));
+ }
+ }
+
+ // Actualizar meta de unidad de venta
+ if (sellUnit) {
+ updatePayload.meta_data = [
+ { key: "_sell_unit_override", value: sellUnit }
+ ];
+ }
+
+ // Solo hacer request si hay algo que actualizar
+ if (Object.keys(updatePayload).length === 0) {
+ return { ok: true, woo_product_id: wooProductId, updated: false };
+ }
+
+ try {
+ const data = await fetchWoo({
+ url,
+ method: "PUT",
+ body: updatePayload,
+ timeout: client.timeout,
+ headers: client.authHeader,
+ });
+
+ if (dbg.wooHttp) console.log("[wooSnapshot] pushProductToWoo", { wooProductId, updated: true });
+
+ return { ok: true, woo_product_id: wooProductId, updated: true, data };
+ } catch (err) {
+ console.error("[wooSnapshot] pushProductToWoo error:", err.message);
+ throw err;
+ }
+}
+
+/**
+ * Obtiene categorías de WooCommerce por nombres
+ */
+async function fetchWooCategoriesByNames({ tenantId, names }) {
+ if (!names || names.length === 0) return [];
+
+ const client = await getWooClient({ tenantId });
+ const allCategories = [];
+ let page = 1;
+
+ // Obtener todas las categorías de Woo (usualmente son pocas)
+ while (true) {
+ const url = `${client.base}/products/categories?per_page=100&page=${page}`;
+ const data = await fetchWoo({
+ url,
+ method: "GET",
+ timeout: client.timeout,
+ headers: client.authHeader,
+ });
+
+ if (!Array.isArray(data) || data.length === 0) break;
+ allCategories.push(...data);
+ if (data.length < 100) break;
+ page++;
+ }
+
+ // Filtrar las que coinciden con los nombres buscados
+ const normalizedNames = names.map(n => String(n).toLowerCase().trim());
+ return allCategories.filter(c =>
+ normalizedNames.includes(String(c.name).toLowerCase().trim()) ||
+ normalizedNames.includes(String(c.slug).toLowerCase().trim())
+ );
+}
+