ux improved

This commit is contained in:
Lucas Tettamanti
2026-01-17 04:13:35 -03:00
parent 98e3d78e3d
commit 63b9ecef61
35 changed files with 4266 additions and 75 deletions

View File

@@ -0,0 +1,68 @@
import { handleListAliases, handleCreateAlias, handleUpdateAlias, handleDeleteAlias } from "../handlers/aliases.js";
export const makeListAliases = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const q = req.query.q || "";
const woo_product_id = req.query.woo_product_id ? parseInt(req.query.woo_product_id, 10) : null;
const limit = req.query.limit || "200";
const result = await handleListAliases({ tenantId, q, woo_product_id, limit });
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};
export const makeCreateAlias = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const { alias, woo_product_id, boost, category_hint, metadata } = req.body || {};
if (!alias || !woo_product_id) {
return res.status(400).json({ ok: false, error: "alias_and_woo_product_id_required" });
}
const result = await handleCreateAlias({ tenantId, alias, woo_product_id, boost, category_hint, metadata });
res.json({ ok: true, item: result });
} catch (err) {
console.error(err);
if (err.code === "23505") { // unique violation
return res.status(409).json({ ok: false, error: "alias_already_exists" });
}
res.status(500).json({ ok: false, error: "internal_error" });
}
};
export const makeUpdateAlias = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const alias = req.params.alias;
const { woo_product_id, boost, category_hint, metadata } = req.body || {};
if (!woo_product_id) {
return res.status(400).json({ ok: false, error: "woo_product_id_required" });
}
const result = await handleUpdateAlias({ tenantId, alias, woo_product_id, boost, category_hint, metadata });
if (!result) {
return res.status(404).json({ ok: false, error: "alias_not_found" });
}
res.json({ ok: true, item: result });
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};
export const makeDeleteAlias = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const alias = req.params.alias;
const result = await handleDeleteAlias({ tenantId, alias });
res.json({ ok: true, ...result });
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};

View File

@@ -1,4 +1,4 @@
import { handleSearchProducts } from "../handlers/products.js";
import { handleSearchProducts, handleListProducts, handleGetProduct, handleSyncProducts } from "../handlers/products.js";
export const makeSearchProducts = (tenantIdOrFn) => async (req, res) => {
try {
@@ -14,3 +14,43 @@ export const makeSearchProducts = (tenantIdOrFn) => async (req, res) => {
}
};
export const makeListProducts = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const q = req.query.q || "";
const limit = req.query.limit || "2000";
const offset = req.query.offset || "0";
const result = await handleListProducts({ tenantId, q, limit, offset });
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};
export const makeGetProduct = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const wooProductId = req.params.id;
const result = await handleGetProduct({ tenantId, wooProductId });
if (!result) {
return res.status(404).json({ ok: false, error: "product_not_found" });
}
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};
export const makeSyncProducts = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const result = await handleSyncProducts({ tenantId });
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};

View File

@@ -0,0 +1,88 @@
import {
handleListRecommendations,
handleGetRecommendation,
handleCreateRecommendation,
handleUpdateRecommendation,
handleDeleteRecommendation
} from "../handlers/recommendations.js";
export const makeListRecommendations = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const q = req.query.q || "";
const limit = req.query.limit || "200";
const result = await handleListRecommendations({ tenantId, q, limit });
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};
export const makeGetRecommendation = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const id = req.params.id;
const result = await handleGetRecommendation({ tenantId, id });
if (!result) {
return res.status(404).json({ ok: false, error: "recommendation_not_found" });
}
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};
export const makeCreateRecommendation = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const { rule_key, trigger, queries, boosts, ask_slots, active, priority } = req.body || {};
if (!rule_key) {
return res.status(400).json({ ok: false, error: "rule_key_required" });
}
const result = await handleCreateRecommendation({
tenantId, rule_key, trigger, queries, boosts, ask_slots, active, priority
});
res.json({ ok: true, item: result });
} catch (err) {
console.error(err);
if (err.code === "23505") { // unique violation
return res.status(409).json({ ok: false, error: "rule_key_already_exists" });
}
res.status(500).json({ ok: false, error: "internal_error" });
}
};
export const makeUpdateRecommendation = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const id = req.params.id;
const { trigger, queries, boosts, ask_slots, active, priority } = req.body || {};
const result = await handleUpdateRecommendation({
tenantId, id, trigger, queries, boosts, ask_slots, active, priority
});
if (!result) {
return res.status(404).json({ ok: false, error: "recommendation_not_found" });
}
res.json({ ok: true, item: result });
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};
export const makeDeleteRecommendation = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const id = req.params.id;
const result = await handleDeleteRecommendation({ tenantId, id });
res.json({ ok: true, ...result });
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};

296
src/modules/0-ui/db/repo.js Normal file
View File

@@ -0,0 +1,296 @@
import { pool } from "../../shared/db/pool.js";
// ─────────────────────────────────────────────────────────────
// Products
// ─────────────────────────────────────────────────────────────
export async function listProducts({ tenantId, q = "", limit = 2000, offset = 0 }) {
const lim = Math.max(1, Math.min(5000, parseInt(limit, 10) || 2000));
const off = Math.max(0, parseInt(offset, 10) || 0);
const query = String(q || "").trim();
let sql, params;
if (query) {
const like = `%${query}%`;
sql = `
select
woo_id as woo_product_id,
name,
slug as sku,
price_current as price,
stock_status,
categories,
attributes_normalized,
updated_at as refreshed_at,
raw as payload
from woo_products_snapshot
where tenant_id = $1
and (name ilike $2 or coalesce(slug,'') ilike $2)
order by name asc
limit $3 offset $4
`;
params = [tenantId, like, lim, off];
} else {
sql = `
select
woo_id as woo_product_id,
name,
slug as sku,
price_current as price,
stock_status,
categories,
attributes_normalized,
updated_at as refreshed_at,
raw as payload
from woo_products_snapshot
where tenant_id = $1
order by name asc
limit $2 offset $3
`;
params = [tenantId, lim, off];
}
const { rows } = await pool.query(sql, params);
return rows;
}
export async function getProductByWooId({ tenantId, wooProductId }) {
const sql = `
select
woo_id as woo_product_id,
name,
slug as sku,
price_current as price,
stock_status,
categories,
attributes_normalized,
updated_at as refreshed_at,
raw as payload
from woo_products_snapshot
where tenant_id = $1 and woo_id = $2
limit 1
`;
const { rows } = await pool.query(sql, [tenantId, wooProductId]);
return rows[0] || null;
}
// ─────────────────────────────────────────────────────────────
// Aliases
// ─────────────────────────────────────────────────────────────
function normalizeAlias(alias) {
return String(alias || "")
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.trim();
}
export async function listAliases({ tenantId, q = "", woo_product_id = null, limit = 200 }) {
const lim = Math.max(1, Math.min(500, parseInt(limit, 10) || 200));
const query = String(q || "").trim();
let sql, params;
if (woo_product_id) {
sql = `
select alias, normalized_alias, woo_product_id, category_hint, boost, metadata, created_at, updated_at
from product_aliases
where tenant_id = $1 and woo_product_id = $2
order by alias asc
limit $3
`;
params = [tenantId, woo_product_id, lim];
} else if (query) {
const like = `%${query}%`;
sql = `
select alias, normalized_alias, woo_product_id, category_hint, boost, metadata, created_at, updated_at
from product_aliases
where tenant_id = $1 and (alias ilike $2 or normalized_alias ilike $2)
order by alias asc
limit $3
`;
params = [tenantId, like, lim];
} else {
sql = `
select alias, normalized_alias, woo_product_id, category_hint, boost, metadata, created_at, updated_at
from product_aliases
where tenant_id = $1
order by alias asc
limit $2
`;
params = [tenantId, lim];
}
const { rows } = await pool.query(sql, params);
return rows;
}
export async function insertAlias({ tenantId, alias, woo_product_id, boost = 0, category_hint = null, metadata = {} }) {
const normalizedAlias = normalizeAlias(alias);
const sql = `
insert into product_aliases (tenant_id, alias, normalized_alias, woo_product_id, category_hint, boost, metadata)
values ($1, $2, $3, $4, $5, $6, $7)
returning alias, normalized_alias, woo_product_id, category_hint, boost, metadata, created_at, updated_at
`;
const { rows } = await pool.query(sql, [
tenantId,
alias.toLowerCase().trim(),
normalizedAlias,
woo_product_id,
category_hint,
boost || 0,
JSON.stringify(metadata || {}),
]);
return rows[0];
}
export async function updateAlias({ tenantId, alias, woo_product_id, boost = 0, category_hint = null, metadata = {} }) {
const normalizedAlias = normalizeAlias(alias);
const sql = `
update product_aliases
set woo_product_id = $3, category_hint = $4, boost = $5, metadata = $6, normalized_alias = $7, updated_at = now()
where tenant_id = $1 and alias = $2
returning alias, normalized_alias, woo_product_id, category_hint, boost, metadata, created_at, updated_at
`;
const { rows } = await pool.query(sql, [
tenantId,
alias.toLowerCase().trim(),
woo_product_id,
category_hint,
boost || 0,
JSON.stringify(metadata || {}),
normalizedAlias,
]);
return rows[0] || null;
}
export async function deleteAlias({ tenantId, alias }) {
const sql = `delete from product_aliases where tenant_id = $1 and alias = $2 returning alias`;
const { rows } = await pool.query(sql, [tenantId, alias.toLowerCase().trim()]);
return rows.length > 0;
}
// ─────────────────────────────────────────────────────────────
// Recommendations
// ─────────────────────────────────────────────────────────────
export async function listRecommendations({ tenantId, q = "", limit = 200 }) {
const lim = Math.max(1, Math.min(500, parseInt(limit, 10) || 200));
const query = String(q || "").trim();
let sql, params;
if (query) {
const like = `%${query}%`;
sql = `
select id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
from product_reco_rules
where tenant_id = $1 and rule_key ilike $2
order by priority desc, rule_key asc
limit $3
`;
params = [tenantId, like, lim];
} else {
sql = `
select id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
from product_reco_rules
where tenant_id = $1
order by priority desc, rule_key asc
limit $2
`;
params = [tenantId, lim];
}
const { rows } = await pool.query(sql, params);
return rows;
}
export async function getRecommendationById({ tenantId, id }) {
const sql = `
select id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
from product_reco_rules
where tenant_id = $1 and id = $2
limit 1
`;
const { rows } = await pool.query(sql, [tenantId, id]);
return rows[0] || null;
}
export async function insertRecommendation({
tenantId,
rule_key,
trigger = {},
queries = [],
boosts = {},
ask_slots = [],
active = true,
priority = 100,
}) {
const sql = `
insert into product_reco_rules (tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority)
values ($1, $2, $3, $4, $5, $6, $7, $8)
returning id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
`;
const { rows } = await pool.query(sql, [
tenantId,
rule_key.toLowerCase().trim(),
JSON.stringify(trigger || {}),
JSON.stringify(queries || []),
JSON.stringify(boosts || {}),
JSON.stringify(ask_slots || []),
active !== false,
priority || 100,
]);
return rows[0];
}
export async function updateRecommendation({
tenantId,
id,
trigger,
queries,
boosts,
ask_slots,
active,
priority,
}) {
const sql = `
update product_reco_rules
set
trigger = $3,
queries = $4,
boosts = $5,
ask_slots = $6,
active = $7,
priority = $8,
updated_at = now()
where tenant_id = $1 and id = $2
returning id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
`;
const { rows } = await pool.query(sql, [
tenantId,
id,
JSON.stringify(trigger || {}),
JSON.stringify(queries || []),
JSON.stringify(boosts || {}),
JSON.stringify(ask_slots || []),
active !== false,
priority || 100,
]);
return rows[0] || null;
}
export async function deleteRecommendation({ tenantId, id }) {
const sql = `delete from product_reco_rules where tenant_id = $1 and id = $2 returning id`;
const { rows } = await pool.query(sql, [tenantId, id]);
return rows.length > 0;
}

View File

@@ -0,0 +1,19 @@
import { listAliases, insertAlias, updateAlias, deleteAlias } from "../db/repo.js";
export async function handleListAliases({ tenantId, q = "", woo_product_id = null, limit = 200 }) {
const items = await listAliases({ tenantId, q, woo_product_id, limit });
return { items };
}
export async function handleCreateAlias({ tenantId, alias, woo_product_id, boost = 0, category_hint = null, metadata = {} }) {
return insertAlias({ tenantId, alias, woo_product_id, boost, category_hint, metadata });
}
export async function handleUpdateAlias({ tenantId, alias, woo_product_id, boost = 0, category_hint = null, metadata = {} }) {
return updateAlias({ tenantId, alias, woo_product_id, boost, category_hint, metadata });
}
export async function handleDeleteAlias({ tenantId, alias }) {
const deleted = await deleteAlias({ tenantId, alias });
return { deleted };
}

View File

@@ -1,4 +1,5 @@
import { searchSnapshotItems } from "../../shared/wooSnapshot.js";
import { listProducts, getProductByWooId } from "../db/repo.js";
export async function handleSearchProducts({ tenantId, q = "", limit = "10", forceWoo = "0" }) {
const { items, source } = await searchSnapshotItems({
@@ -9,3 +10,18 @@ export async function handleSearchProducts({ tenantId, q = "", limit = "10", for
return { items, source };
}
export async function handleListProducts({ tenantId, q = "", limit = 2000, offset = 0 }) {
const items = await listProducts({ tenantId, q, limit, offset });
return { items };
}
export async function handleGetProduct({ tenantId, wooProductId }) {
return getProductByWooId({ 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)" };
}

View File

@@ -0,0 +1,47 @@
import {
listRecommendations,
getRecommendationById,
insertRecommendation,
updateRecommendation,
deleteRecommendation,
} from "../db/repo.js";
export async function handleListRecommendations({ tenantId, q = "", limit = 200 }) {
const items = await listRecommendations({ tenantId, q, limit });
return { items };
}
export async function handleGetRecommendation({ tenantId, id }) {
return getRecommendationById({ tenantId, id });
}
export async function handleCreateRecommendation({
tenantId,
rule_key,
trigger = {},
queries = [],
boosts = {},
ask_slots = [],
active = true,
priority = 100,
}) {
return insertRecommendation({ tenantId, rule_key, trigger, queries, boosts, ask_slots, active, priority });
}
export async function handleUpdateRecommendation({
tenantId,
id,
trigger,
queries,
boosts,
ask_slots,
active,
priority,
}) {
return updateRecommendation({ tenantId, id, trigger, queries, boosts, ask_slots, active, priority });
}
export async function handleDeleteRecommendation({ tenantId, id }) {
const deleted = await deleteRecommendation({ tenantId, id });
return { deleted };
}