separated in modules

This commit is contained in:
Lucas Tettamanti
2026-01-15 22:45:33 -03:00
parent eedd16afdb
commit ea62385e3d
41 changed files with 1116 additions and 2918 deletions

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