← back to Zuber Internal
server.js
286 lines
/* Zuber internal line viewer — Designer Wallcoverings (INTERNAL, basic-auth).
Reads dw_unified.zuber_catalog LIVE (via psql, local) OR a self-contained
data/zuber.jsonl bundle (Kamatera — NEVER touches a DB).
Left rail is ported to full all.designerwallcoverings.com parity:
- active-filter chip row (removable pills + clear-all, persisted)
- collapsible <details.sec> facet sections w/ per-value counts
- CROSS-FILTERED facets (each dim's counts skip its own selection)
- Color families (hue buckets, swatch dot + count, family-ordered)
- Style / Tag explosion (JSONB arrays), Type, Book/Series (collection),
Material (fallback composition), Price (Quote-only), Image (present/missing)
Single-vendor line → NO Vendor facet. Quote-only, made-to-order heritage line.
NOT on Shopify. Color sort + density + created-date chips preserved. */
const express = require('express');
const { execFile } = require('child_process');
const path = require('path');
const fs = require('fs');
const PORT = process.env.PORT || 10074;
const PGURI = process.env.PGURI || 'postgresql://localhost/dw_unified';
const [AUTH_USER, AUTH_PASS] = (process.env.BASIC_AUTH || 'admin:DW2024!').split(':');
const REFRESH_SEC = Number(process.env.REFRESH_INTERVAL_SEC || 300);
// AUTO-DETECT data source: self-contained jsonl bundle (no DB/psql — Kamatera) vs
// live psql (local). jsonl mode when DATA_SOURCE=jsonl OR the bundle exists.
const JSONL = path.join(__dirname, 'data', 'zuber.jsonl');
const USE_JSONL = process.env.DATA_SOURCE === 'jsonl' || (process.env.DATA_SOURCE !== 'pg' && fs.existsSync(JSONL));
const app = express();
app.use(require('compression')());
// unauthenticated health probe BEFORE the gate — keep it a bare liveness signal
app.get('/healthz', (_req, res) => res.json({ ok: true }));
app.use((req, res, next) => {
const [scheme, b64] = (req.headers.authorization || '').split(' ');
if (scheme === 'Basic' && b64) {
const [u, p] = Buffer.from(b64, 'base64').toString('utf8').split(':');
if (u === AUTH_USER && p === AUTH_PASS) return next();
}
res.setHeader('WWW-Authenticate', 'Basic realm="Zuber Internal"');
return res.status(401).send('Authentication required.');
});
// ── color families (hue buckets) ─────────────────────────────────────────────
// Interior-designer wheel order; each family carries a representative swatch hex
// for the rail dot. A product's color set is derived from its ai_colors[].name +
// color_hex (name keyword first, hue fallback). IDENTICAL in psql + jsonl paths.
const FAMILY_ORDER = ['Red', 'Orange', 'Yellow', 'Gold', 'Green', 'Teal', 'Blue', 'Purple', 'Pink', 'Brown', 'Beige', 'Black', 'Gray', 'White', 'Silver', 'Metallic', 'Multi'];
const FAMILY_SWATCH = {
Red: '#c0392b', Orange: '#e67e22', Yellow: '#f1c40f', Gold: '#c9a227', Green: '#27ae60',
Teal: '#17a2a2', Blue: '#2e6fd6', Purple: '#8e44ad', Pink: '#e28ac0', Brown: '#8b5a2b',
Beige: '#d8c9a8', Black: '#222222', Gray: '#8a8a8a', White: '#f2f2ee', Silver: '#c0c4c8',
Metallic: '#b8a06a', Multi: 'linear-gradient(90deg,#e74c3c,#f1c40f,#27ae60,#2e6fd6,#8e44ad)',
};
// Name-keyword → family. Longer/more-specific keys first via ordered array.
const NAME_FAMILY = [
[/gold|golden|ambre|amber|aurore/i, 'Gold'],
[/silver/i, 'Silver'],
[/metallic|bronze|copper|brass/i, 'Metallic'],
[/terracotta|rust|burnt|orange/i, 'Orange'],
[/teal|turquoise|aqua|cyan/i, 'Teal'],
[/sky\s*blue|light\s*blue|navy|indigo|cobalt|blue/i, 'Blue'],
[/green|olive|sage|emerald|foliage/i, 'Green'],
[/purple|violet|lavender|mauve|plum/i, 'Purple'],
[/pink|rose|blush|magenta|fuchsia/i, 'Pink'],
[/red|crimson|scarlet|burgundy|maroon/i, 'Red'],
[/yellow|lemon|mustard|ochre|ocher/i, 'Yellow'],
[/brown|earth|sepia|chocolate|tan|umber|khaki/i, 'Brown'],
[/beige|cream|ivory|ecru|taupe|sand|oatmeal|linen|wheat/i, 'Beige'],
[/black|ebony|onyx|charcoal/i, 'Black'],
[/gray|grey|slate|ash/i, 'Gray'],
[/white|alabaster|snow/i, 'White'],
];
function hueOf(hex) {
if (!hex || hex[0] !== '#' || hex.length !== 7) return null;
const r = parseInt(hex.slice(1, 3), 16) / 255, g = parseInt(hex.slice(3, 5), 16) / 255, b = parseInt(hex.slice(5, 7), 16) / 255;
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
const l = (mx + mn) / 2;
if (d < 0.06) { // near-gray: split by lightness
if (l > 0.85) return 'White'; if (l < 0.18) return 'Black'; return 'Gray';
}
let h; if (mx === r) h = ((g - b) / d) % 6; else if (mx === g) h = (b - r) / d + 2; else h = (r - g) / d + 4;
h *= 60; if (h < 0) h += 360;
if (h < 15 || h >= 345) return 'Red';
if (h < 40) return 'Orange';
if (h < 65) return l < 0.45 ? 'Brown' : 'Yellow';
if (h < 160) return 'Green';
if (h < 195) return 'Teal';
if (h < 255) return 'Blue';
if (h < 290) return 'Purple';
if (h < 345) return 'Pink';
return 'Multi';
}
function nameFamily(name) {
if (!name) return null;
for (const [re, fam] of NAME_FAMILY) if (re.test(name)) return fam;
return null;
}
// Derive the de-duped, family-ordered set of color families for one row.
function colorFamilies(aiColors, colorHex) {
const fams = new Set();
for (const c of (aiColors || [])) {
const f = nameFamily(c && c.name) || hueOf(c && c.hex);
if (f) fams.add(f);
}
if (!fams.size && colorHex) { const f = hueOf(colorHex); if (f) fams.add(f); }
return FAMILY_ORDER.filter((f) => fams.has(f));
}
// ---- data load (psql -> JSON) --------------------------------------------
let ROWS = [];
let LAST_REFRESH = new Date().toISOString();
const Q = `SELECT json_agg(row_to_json(t)) FROM (
SELECT mfr_sku, pattern_name, pattern_name_fr, color_name, collection, product_type,
material, composition, width, image_url, product_url, description,
coalesce(all_images,'') AS all_images,
ai_colors, ai_styles, ai_tags, color_hex,
to_char(coalesce(crawled_at, last_scraped, now()),'YYYY-MM-DD"T"HH24:MI:SS') AS created_at
FROM zuber_catalog ORDER BY id
) t;`;
// Map raw catalog rows (psql row_to_json OR jsonl bundle — identical shape) into
// the in-memory ROWS the API serves. Color families + style/tag explosion baked here.
function mapRows(raw) {
ROWS = raw.map((r, i) => {
let imgs = [];
if (r.all_images) { try { imgs = JSON.parse(r.all_images); } catch { imgs = []; } }
if (!imgs.length && r.image_url) imgs = [r.image_url];
const parseArr = (v) => Array.isArray(v) ? v : (typeof v === 'string' && v.trim() ? (() => { try { return JSON.parse(v); } catch { return []; } })() : []);
const aiColors = parseArr(r.ai_colors);
const aiStyles = parseArr(r.ai_styles);
const aiTags = parseArr(r.ai_tags);
const material = r.material || r.composition || null; // Material facet fallback → composition
const image = r.image_url || imgs[0] || null;
const colorHex = r.color_hex || (aiColors[0] && aiColors[0].hex) || null;
return {
idx: i,
sku: r.mfr_sku,
mfr_sku: r.mfr_sku,
pattern_name: r.pattern_name || r.mfr_sku,
pattern_name_fr: r.pattern_name_fr || null,
color_name: r.color_name || (aiColors[0] && aiColors[0].name) || null,
collection: r.collection || null,
product_type: r.product_type || null,
material,
width: r.width || null,
image,
images: imgs,
image_count: imgs.length,
image_state: image ? 'Has image' : 'No image',
product_url: r.product_url || null,
description: r.description || null,
ai_colors: aiColors,
ai_styles: aiStyles,
ai_tags: aiTags,
color_hex: colorHex,
colors: colorFamilies(aiColors, colorHex), // family-ordered color set for the facet + chips
price_band: 'Quote-only', // whole heritage line is quote-only, made-to-order
created_at: r.created_at || null,
};
});
LAST_REFRESH = new Date().toISOString();
console.log(`[zuber] loaded ${ROWS.length} products (source=${USE_JSONL ? 'jsonl' : 'psql'})`);
}
function load() {
if (USE_JSONL) {
try {
const raw = fs.readFileSync(JSONL, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
mapRows(raw);
} catch (e) { console.error('[zuber] jsonl load failed:', e.message); }
return;
}
execFile('psql', [PGURI, '-tAqc', Q], { maxBuffer: 64 * 1024 * 1024 }, (err, stdout) => {
if (err) { console.error('[zuber] load failed:', err.message); return; }
try {
const raw = JSON.parse(stdout.trim() || 'null') || [];
mapRows(raw);
} catch (e) { console.error('[zuber] parse failed:', e.message); }
});
}
load();
// Only poll psql against a live DB; the jsonl bundle is static.
if (!USE_JSONL) setInterval(load, REFRESH_SEC * 1000);
// ---- filtering + cross-filtered facets -----------------------------------
// Parse the multi-select filter state from the query string. Values are '|'-joined.
function parseFilters(q) {
const sel = (k) => { const v = q[k]; return new Set(v ? v.toString().split('|').filter(Boolean) : []); };
return {
q: (q.q || '').toString().trim().toLowerCase(),
collection: sel('collection'), types: sel('product_type'), materials: sel('material'),
colors: sel('color'), styles: sel('style'), tags: sel('tag'), prices: sel('price'), images: sel('image'),
};
}
// Apply every active filter EXCEPT the one named in opts.skip — that's what makes the
// facet counts cross-filtered (all.dw parity): a dimension's tally reflects the OTHER
// selections but not its own, so its own options never collapse to the picked value.
function applyFilters(rows, f, opts = {}) {
const hasAny = (arr, set) => set.size === 0 || arr.some((v) => set.has(v));
const inSet = (val, set) => set.size === 0 || set.has(val ?? '');
let out = rows;
if (opts.skip !== 'collection' && f.collection.size) out = out.filter((r) => inSet(r.collection, f.collection));
if (opts.skip !== 'type' && f.types.size) out = out.filter((r) => inSet(r.product_type, f.types));
if (opts.skip !== 'material' && f.materials.size) out = out.filter((r) => inSet(r.material, f.materials));
if (opts.skip !== 'color' && f.colors.size) out = out.filter((r) => hasAny(r.colors, f.colors));
if (opts.skip !== 'style' && f.styles.size) out = out.filter((r) => hasAny(r.ai_styles, f.styles));
if (opts.skip !== 'tag' && f.tags.size) out = out.filter((r) => hasAny(r.ai_tags, f.tags));
if (opts.skip !== 'price' && f.prices.size) out = out.filter((r) => inSet(r.price_band, f.prices));
if (opts.skip !== 'image' && f.images.size) out = out.filter((r) => inSet(r.image_state, f.images));
if (opts.skip !== 'q' && f.q) out = out.filter((r) => hay(r).includes(f.q));
return out;
}
const hay = (r) => ((r.pattern_name || '') + ' ' + (r.pattern_name_fr || '') + ' ' + (r.mfr_sku || '') + ' ' + (r.collection || '') + ' ' + (r.description || '')).toLowerCase();
// tally a scalar field into { value: count }; tallyArr for an array field (styles/tags/colors).
function tally(rows, key) { const o = {}; for (const r of rows) { const v = r[key]; if (v != null && v !== '') o[v] = (o[v] || 0) + 1; } return o; }
function tallyArr(rows, key) { const o = {}; for (const r of rows) for (const v of (r[key] || [])) { if (v != null && v !== '') o[v] = (o[v] || 0) + 1; } return o; }
// ---- api ------------------------------------------------------------------
const cmp = (a, b) => String(a ?? '').localeCompare(String(b ?? ''), undefined, { sensitivity: 'base' });
function hue(hex) {
if (!hex || hex[0] !== '#' || hex.length !== 7) return 999;
const r = parseInt(hex.slice(1, 3), 16) / 255, g = parseInt(hex.slice(3, 5), 16) / 255, b = parseInt(hex.slice(5, 7), 16) / 255;
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
if (d === 0) return 999;
let h;
if (mx === r) h = ((g - b) / d) % 6; else if (mx === g) h = (b - r) / d + 2; else h = (r - g) / d + 4;
h *= 60; if (h < 0) h += 360;
return h;
}
const sorters = {
newest: null,
pattern: (a, b) => cmp(a.pattern_name, b.pattern_name),
collection: (a, b) => cmp(a.collection, b.collection) || cmp(a.pattern_name, b.pattern_name),
sku: (a, b) => cmp(a.mfr_sku, b.mfr_sku),
color: (a, b) => (hue(a.color_hex) - hue(b.color_hex)) || cmp(a.color_name, b.color_name) || cmp(a.pattern_name, b.pattern_name),
};
app.get('/api/config', (_req, res) => res.json({
title: 'Zuber — Designer Wallcoverings (internal)', total: ROWS.length,
lastRefresh: LAST_REFRESH, samples_only: true,
}));
// Cross-filtered facets — each dimension's counts skip its own selection.
app.get('/api/facets', (req, res) => {
const f = parseFilters(req.query);
res.json({
total: applyFilters(ROWS, f).length,
all: ROWS.length,
product_type: tally(applyFilters(ROWS, f, { skip: 'type' }), 'product_type'),
collection: tally(applyFilters(ROWS, f, { skip: 'collection' }), 'collection'),
color: tallyArr(applyFilters(ROWS, f, { skip: 'color' }), 'colors'),
style: tallyArr(applyFilters(ROWS, f, { skip: 'style' }), 'ai_styles'),
material: tally(applyFilters(ROWS, f, { skip: 'material' }), 'material'),
price: tally(applyFilters(ROWS, f, { skip: 'price' }), 'price_band'),
tag: tallyArr(applyFilters(ROWS, f, { skip: 'tag' }), 'ai_tags'),
image_state: tally(applyFilters(ROWS, f, { skip: 'image' }), 'image_state'),
family_order: FAMILY_ORDER, family_swatch: FAMILY_SWATCH,
});
});
app.get('/api/products', (req, res) => {
const f = parseFilters(req.query);
const sort = (req.query.sort || 'newest').toString();
let rows = applyFilters(ROWS, f);
if (sorters[sort]) rows = [...rows].sort(sorters[sort]);
res.json({ total: rows.length, all: ROWS.length, products: rows });
});
app.use(express.static(path.join(__dirname, 'public')));
// ---- Internal purchasing requests: Request Memo / Check Stock / Get Price ----
// Shared module every internal vendor viewer uses (astek/crezana reference). Logs a
// REQ to dw_unified.vendor_requests; stock/price auto-email the vendor when an email
// is on file. Behind Basic Auth (the click is the approval). Zuber is a quote-only,
// made-to-order heritage line with no vendor email on file → requests log + prompt to call.
app.use(express.json());
require('./lib/vendor-requests').mountVendorRequests(app, {
vendorCode: 'zuber',
getVendor: () => ({ name: 'Zuber & Cie' }),
dataDir: __dirname + '/data',
});
app.listen(PORT, () => console.log(`Zuber internal viewer -> http://127.0.0.1:${PORT} (basic-auth ${AUTH_USER}, source=${USE_JSONL ? 'jsonl' : 'psql'})`));