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()) + ); +} +