separated in modules
This commit is contained in:
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user