← back to Crezana Internal

server.js

239 lines

// Crezana internal line viewer — basic-auth, reads crezana_catalog. Port 10072 (local) / 10106 (Kamatera).
// Internal curation surface per internal-line-viewer spec. QUOTE-ONLY, product_type Fabric.
// Rail upgraded to full feature-parity with all.designerwallcoverings.com's rail
// (active-filter chip row + collapsible facet sections w/ counts, type-ahead, cap/expand,
//  color-FAMILY bucketing, style/tag explosion). Single-vendor line → NO Vendor facet.
//
// AUTO-DETECT data source: a self-contained jsonl bundle (no DB — for Kamatera) or
// live Postgres (local). In BOTH modes the catalog is loaded ONCE into memory, each row is
// ENRICHED once (color families from hex/ai_colors, style/tag/material explosion, image_state),
// and every endpoint is served from that array. Color-family bucketing + explosion are
// IDENTICAL in both modes (they run on the in-memory rows, never in SQL). jsonl mode when
// DATA_SOURCE=jsonl OR the bundle exists — and in jsonl mode the pg pool is NEVER required.
const express = require('express');
const basicAuth = require('basic-auth');
const path = require('path');
const fs = require('fs');

const PORT = process.env.PORT || 10072;
const JSONL = path.join(__dirname, 'data', 'crezana.jsonl');
const USE_JSONL = process.env.DATA_SOURCE === 'jsonl' || (process.env.DATA_SOURCE !== 'pg' && fs.existsSync(JSONL));

const app = express();

// ---- Basic auth (admin / DW2024!) ----
app.use((req, res, next) => {
  // Read-only feed endpoints stay open so the all.dw aggregator can crawl this
  // co-located viewer on localhost (quote-only line — no cost/net/wholesale exposed).
  if (req.path === '/api/health' || req.path === '/api/products' || req.path === '/api/facets') return next();
  const cred = basicAuth(req);
  if (!cred || cred.name !== 'admin' || cred.pass !== 'DW2024!') {
    res.set('WWW-Authenticate', 'Basic realm="Crezana Internal"');
    return res.status(401).send('Auth required');
  }
  next();
});

app.use(express.static(__dirname + '/public'));

// ---- Internal purchasing requests: Request Memo / Check Stock / Get Price ----
// Same shared module every internal vendor viewer uses (astek 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). Crezana is a
// quote-only line with no vendor email on file, so requests log + prompt to call.
app.use(express.json());
require('./lib/vendor-requests').mountVendorRequests(app, {
  vendorCode: 'crezana',
  getVendor: () => ({ name: 'Crezana Design' }),
  dataDir: __dirname + '/data',
});

// ─── shared enrichment (identical in jsonl + pg modes — runs on in-memory rows) ──────────
// Color FAMILIES: map every hex in color_hex/dominant_color_hex/ai_colors into a hue bucket,
// family-ordered. Also fold in ai_colors *names* that are already family words.
const FAMILY_ORDER = ['Red', 'Orange', 'Brown', 'Yellow', 'Gold', 'Green', 'Teal', 'Blue', 'Purple', 'Pink', 'Beige', 'Cream', 'Black', 'Gray', 'White', 'Multi'];
// swatch color for each family dot (front-end reads facet.dot)
const FAMILY_HEX = {
  Red: '#c0392b', Orange: '#e67e22', Brown: '#8b5a2b', Yellow: '#f1c40f', Gold: '#c9a227',
  Green: '#27ae60', Teal: '#1abc9c', Blue: '#2980b9', Purple: '#8e44ad', Pink: '#e84393',
  Beige: '#d2b48c', Cream: '#f5f0e1', Black: '#1a1a1a', Gray: '#8b8f96', White: '#f5f5f5', Multi: 'linear-gradient(90deg,#c0392b,#27ae60,#2980b9)',
};
// map a family word (from ai_colors names) → canonical family bucket
const NAME_TO_FAMILY = {
  red: 'Red', crimson: 'Red', maroon: 'Red', burgundy: 'Red', rust: 'Red', coral: 'Red', terracotta: 'Orange',
  orange: 'Orange', peach: 'Orange', apricot: 'Orange', amber: 'Orange',
  brown: 'Brown', tan: 'Brown', taupe: 'Brown', chocolate: 'Brown', khaki: 'Brown', 'light brown': 'Brown', 'dark brown': 'Brown', mocha: 'Brown', umber: 'Brown', sienna: 'Brown', chestnut: 'Brown',
  yellow: 'Yellow', mustard: 'Yellow', lemon: 'Yellow',
  gold: 'Gold', golden: 'Gold', brass: 'Gold', bronze: 'Gold',
  green: 'Green', olive: 'Green', sage: 'Green', mint: 'Green', emerald: 'Green', forest: 'Green', lime: 'Green', 'light green': 'Green', 'dark green': 'Green',
  teal: 'Teal', turquoise: 'Teal', aqua: 'Teal', cyan: 'Teal',
  blue: 'Blue', navy: 'Blue', 'light blue': 'Blue', 'dark blue': 'Blue', 'sky blue': 'Blue', indigo: 'Blue', cobalt: 'Blue', denim: 'Blue',
  purple: 'Purple', violet: 'Purple', lavender: 'Purple', plum: 'Purple', mauve: 'Purple', lilac: 'Purple',
  pink: 'Pink', rose: 'Pink', blush: 'Pink', magenta: 'Pink', fuchsia: 'Pink',
  beige: 'Beige', sand: 'Beige', ecru: 'Beige', 'neutral beige': 'Beige', 'light beige': 'Beige', 'warm brown': 'Brown', neutral: 'Beige', natural: 'Beige', wheat: 'Beige',
  cream: 'Cream', ivory: 'Cream', 'off-white': 'Cream', 'off white': 'Cream', vanilla: 'Cream', linen: 'Cream',
  black: 'Black', charcoal: 'Black', ebony: 'Black', onyx: 'Black',
  gray: 'Gray', grey: 'Gray', silver: 'Gray', 'light gray': 'Gray', 'dark gray': 'Gray', 'light grey': 'Gray', 'dark grey': 'Gray', slate: 'Gray', graphite: 'Gray', pewter: 'Gray',
  white: 'White', 'pure white': 'White', snow: 'White',
};
function hexFamily(hex) {
  if (!hex) return null;
  const m = /^#?([0-9a-f]{6})$/i.exec(String(hex).trim());
  if (!m) return null;
  const n = parseInt(m[1], 16);
  const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
  const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
  const light = (mx + mn) / 2 / 255; // 0..1
  // near-neutral (low saturation) → white / cream / beige / gray / black by lightness
  if (d < 26) {
    if (light > 0.90) return 'White';
    if (light > 0.80) return 'Cream';
    if (light > 0.16) return 'Gray';
    return 'Black';
  }
  // hue in degrees
  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 = (h * 60 + 360) % 360;
  // low-light warm hues read as brown; very light warm read as beige/cream
  if (h < 15 || h >= 345) return 'Red';
  if (h < 45) { // orange band → brown when dark, orange when bright
    if (light < 0.42) return 'Brown';
    if (light > 0.82 && d < 90) return 'Beige';
    return 'Orange';
  }
  if (h < 65) { if (light < 0.42) return 'Brown'; return light > 0.78 ? 'Gold' : 'Yellow'; }
  if (h < 80) return 'Gold';
  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 rowColorFamilies(r) {
  const fams = new Set();
  // from ai_colors: prefer the name if it's a known family word, else bucket its hex
  let ac = r.ai_colors;
  if (typeof ac === 'string') { try { ac = JSON.parse(ac); } catch { ac = []; } }
  if (Array.isArray(ac)) {
    for (const c of ac) {
      const nm = (c && c.name ? String(c.name) : '').toLowerCase().trim();
      const byName = NAME_TO_FAMILY[nm];
      if (byName) { fams.add(byName); continue; }
      const byHex = hexFamily(c && c.hex);
      if (byHex) fams.add(byHex);
    }
  }
  // fold in the primary hex(es) too
  for (const h of [r.dominant_color_hex, r.color_hex]) { const f = hexFamily(h); if (f) fams.add(f); }
  return [...fams].sort((a, b) => FAMILY_ORDER.indexOf(a) - FAMILY_ORDER.indexOf(b));
}
// Material facet — Crezana data carries NO material/composition column, so derive fabric-content
// terms out of ai_tags + ai_styles (Woven, Linen, Cotton, Silk, Velvet, Embroidered, …).
const MATERIAL_TERMS = {
  woven: 'Woven', linen: 'Linen', cotton: 'Cotton', silk: 'Silk', velvet: 'Velvet', wool: 'Wool',
  embroidered: 'Embroidered', embroidery: 'Embroidered', jute: 'Jute', burlap: 'Burlap', leather: 'Leather',
  suede: 'Suede', chenille: 'Chenille', jacquard: 'Jacquard', boucle: 'Bouclé', 'bouclé': 'Bouclé',
  grasscloth: 'Grasscloth', sisal: 'Sisal', canvas: 'Canvas', tweed: 'Tweed', damask: 'Damask',
  metallic: 'Metallic', beaded: 'Beaded', 'faux leather': 'Faux Leather',
};
function rowMaterials(r) {
  const out = new Set();
  const feed = [].concat(explode(r.ai_tags), explode(r.ai_styles));
  for (const t of feed) { const m = MATERIAL_TERMS[String(t).toLowerCase().trim()]; if (m) out.add(m); }
  return [...out];
}
function explode(v) {
  if (typeof v === 'string') { try { v = JSON.parse(v); } catch { return []; } }
  if (!Array.isArray(v)) return [];
  // title-case-fold so "elegant"/"Elegant" collapse to one value
  return v.filter((x) => x != null && x !== '').map((x) => String(x));
}
const titleCase = (s) => String(s).replace(/\w\S*/g, (t) => t.charAt(0).toUpperCase() + t.slice(1).toLowerCase());
function foldList(v) {
  const seen = new Map();
  for (const raw of explode(v)) { const k = titleCase(raw); if (!seen.has(k)) seen.set(k, k); }
  return [...seen.values()];
}
function enrich(r) {
  r.colors = rowColorFamilies(r);
  r.styles = foldList(r.ai_styles);
  r.tags = foldList(r.ai_tags);
  r.materials = rowMaterials(r);
  r.image_state = (r.image_url && String(r.image_url).trim()) ? 'Has image' : 'No image';
  // QUOTE-ONLY line → single price band
  r.price_band = 'Quote-only';
  return r;
}

// ---- in-memory catalog (jsonl bundle OR live Postgres) --------------------
let ROWS = [];

async function load() {
  if (USE_JSONL) {
    // HARD RAIL: jsonl mode NEVER touches a DB — no pool is even required here.
    ROWS = fs.readFileSync(JSONL, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
  } else {
    const { Pool } = require('pg');
    const pool = new Pool({ host: process.env.PGHOST || '/tmp', database: 'dw_unified' });
    const { rows } = await pool.query(`
      SELECT id, mfr_sku, dw_sku, pattern_name, color_name, collection, product_type,
             image_url, product_url, description, color_hex, dominant_color_hex,
             ai_colors, ai_styles, ai_tags, crawled_at, last_scraped
      FROM crezana_catalog
      ORDER BY id`);
    await pool.end();
    ROWS = rows;
  }
  ROWS.forEach(enrich);
  console.log(`[crezana] loaded ${ROWS.length} products (source=${USE_JSONL ? 'jsonl' : 'pg'})`);
}

// Facet counts for the collapsible field sections — {v,n[,dot]} lists, count desc.
// Single-vendor line → NO vendor facet. Color is family-bucketed & family-ordered w/ swatch.
function facets() {
  const scalar = (col) => {
    const m = new Map();
    for (const r of ROWS) { const v = r[col]; if (v == null || v === '') continue; m.set(v, (m.get(v) || 0) + 1); }
    return [...m.entries()].sort((a, b) => b[1] - a[1]).map(([v, n]) => ({ v, n }));
  };
  const listCol = (col, opts = {}) => { // col already holds an array on each enriched row
    const m = new Map();
    for (const r of ROWS) { for (const val of (r[col] || [])) { if (val == null || val === '') continue; m.set(val, (m.get(val) || 0) + 1); } }
    let e = [...m.entries()];
    if (opts.order) e.sort((a, b) => opts.order.indexOf(a[0]) - opts.order.indexOf(b[0]));
    else e.sort((a, b) => b[1] - a[1]);
    return e.map(([v, n]) => ({ v, n, ...(opts.dot ? { dot: FAMILY_HEX[v] || '' } : {}) }));
  };
  return {
    product_type: scalar('product_type'),
    collection: scalar('collection'),
    color: listCol('colors', { order: FAMILY_ORDER, dot: true }),
    style: listCol('styles'),
    material: listCol('materials'),
    price_band: [{ v: 'Quote-only', n: ROWS.length }],
    tags: listCol('tags'),
    image_state: scalar('image_state'),
    family_order: FAMILY_ORDER,
  };
}

// Full products list (raw enriched rows — the client sorts/filters/renders).
app.get('/api/products', (req, res) => {
  res.json({ count: ROWS.length, products: ROWS });
});

app.get('/api/facets', (req, res) => {
  try { res.json(facets()); }
  catch (e) { res.status(500).json({ error: e.message }); }
});

app.get('/api/health', (req, res) => res.json({ ok: true, service: 'crezana-internal', count: ROWS.length, source: USE_JSONL ? 'jsonl' : 'pg' }));

load().then(() => {
  app.listen(PORT, () => console.log(`crezana-internal on :${PORT} (source=${USE_JSONL ? 'jsonl' : 'pg'})`));
}).catch((e) => { console.error('[crezana] load failed', e); process.exit(1); });