audit and sync

This commit is contained in:
Lucas Tettamanti
2026-01-18 19:00:49 -03:00
parent 3b39e706af
commit 23c3d44490
9 changed files with 404 additions and 19 deletions

View File

@@ -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;

View File

@@ -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
});
}

View File

@@ -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
};
}

View File

@@ -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));

View File

@@ -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,
});
};
}

View File

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