← 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'})`));