← back to Fromental Internal

server.js

311 lines

/* Fromental — INTERNAL line viewer (basic-auth, read-only).
   Built to Steve's internal-line-viewer standing spec (astek-landing :9944) AND upgraded to
   full feature-parity with all.designerwallcoverings.com's Amazon-style left rail:
     - active-filter chip row (#activeRow) with removable pills + Clear all
     - collapsible <details class="sec"> facet sections, each with a live count badge (.n)
     - Amazon-style checkbox rows + counts + type-ahead (.ffind) + cap/expand "show more/less"
     - server-computed /api/facets that recomputes each facet's counts from the CURRENT filtered
       set (drill-down), the SAME way in BOTH data modes.

   DATA PATHS (a HARD rail): jsonl mode NEVER touches a DB.
     - LOCAL  : live Postgres over dw_unified.fromental_catalog (in-memory snapshot, periodic reload)
     - KAMATERA: self-contained data/fromental.jsonl loaded into memory (static; no DB, no reload)
   The JSONL rows are the exact pg row shape, so the mapping + facet logic below is mode-agnostic. */
const express = require('express');
const path = require('path');
const fs = require('fs');

const PORT = process.env.PORT || 10071;
const DB = process.env.DATABASE_URL || 'postgresql://localhost/dw_unified';
const REFRESH_INTERVAL_SEC = Number(process.env.REFRESH_INTERVAL_SEC || 900);
const [AUTH_USER, AUTH_PASS] = (process.env.BASIC_AUTH || 'admin:DW2024!').split(':');

// AUTO-DETECT data source: jsonl bundle (self-contained, no DB — for Kamatera)
// vs live Postgres (local). jsonl mode when DATA_SOURCE=jsonl OR the bundle exists.
const JSONL = path.join(__dirname, 'data', 'fromental.jsonl');
const USE_JSONL = process.env.DATA_SOURCE === 'jsonl' || (process.env.DATA_SOURCE !== 'pg' && fs.existsSync(JSONL));

const app = express();
app.use(require('compression')());

let SNAP = { products: [], count: 0 };
let LAST_REFRESH = new Date().toISOString();

// ── color families (hue buckets) ─────────────────────────────────────────────
// A single hex → a coarse hue/lightness family. Ordered light→warm→cool→dark so the
// Color facet reads like a paint deck. Each family carries a representative swatch hex
// so the rail can render a color dot next to the family name.
const FAMILY_ORDER = ['White', 'Cream', 'Beige', 'Grey', 'Silver', 'Warm', 'Red', 'Orange', 'Yellow', 'Green', 'Teal', 'Blue', 'Purple', 'Pink', 'Brown', 'Black', 'Neutral'];
const FAMILY_SWATCH = {
  White: '#f5f3ee', Cream: '#efe7d0', Beige: '#d8c7a8', Grey: '#9a9a9a', Silver: '#c4c7cc',
  Warm: '#c8a26a', Red: '#c0392b', Orange: '#e07b39', Yellow: '#e4c14a', Green: '#4c8b52',
  Teal: '#2f9c94', Blue: '#3f6fb0', Purple: '#7a5aa0', Pink: '#d98aa8', Brown: '#6b4a30',
  Black: '#1c1c1c', Neutral: '#b9b2a6',
};
function hexToRgb(hex) {
  if (!hex || hex[0] !== '#' || (hex.length !== 7 && hex.length !== 4)) return null;
  let h = hex;
  if (h.length === 4) h = '#' + h[1] + h[1] + h[2] + h[2] + h[3] + h[3];
  const r = parseInt(h.slice(1, 3), 16), g = parseInt(h.slice(3, 5), 16), b = parseInt(h.slice(5, 7), 16);
  if ([r, g, b].some(Number.isNaN)) return null;
  return [r, g, b];
}
function rgbToHsl(r, g, b) {
  r /= 255; g /= 255; b /= 255;
  const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
  let h = 0; const l = (mx + mn) / 2; const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
  if (d !== 0) {
    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, s, l];
}
// Map a hex to one of FAMILY_ORDER. Lightness/saturation first (achromatics), then hue.
function hexToFamily(hex) {
  const rgb = hexToRgb(hex);
  if (!rgb) return null;
  const [h, s, l] = rgbToHsl(rgb[0], rgb[1], rgb[2]);
  if (l >= 0.92) return 'White';
  if (l <= 0.09) return 'Black';
  if (s < 0.12) {           // near-grey axis
    if (l >= 0.75) return 'Silver';
    if (l >= 0.55) return 'Grey';
    return 'Grey';
  }
  if (s < 0.28 && l >= 0.62) {  // muted, light → soft neutrals
    if (h >= 30 && h <= 70) return 'Cream';
    return 'Beige';
  }
  // low-sat mid/dark warm → Brown / Warm
  if (s < 0.45 && l < 0.5 && (h < 45 || h >= 330)) return 'Brown';
  if (h < 15 || h >= 345) return 'Red';
  if (h < 40) return (l < 0.45 ? 'Brown' : (s < 0.5 ? 'Warm' : 'Orange'));
  if (h < 50) return 'Orange';
  if (h < 70) return 'Yellow';
  if (h < 160) return 'Green';
  if (h < 195) return 'Teal';
  if (h < 255) return 'Blue';
  if (h < 300) return 'Purple';
  if (h < 345) return 'Pink';
  return 'Neutral';
}

// ── price bands (tuned to the Fromental range: $20 – $13,745, median ~$1,015) ──
const PRICE_BANDS = [
  { key: 'Quote-only', test: null },              // special: matched by p.quote, handled below
  { key: '≤$100', test: (p) => p <= 100 },
  { key: '$100–500', test: (p) => p > 100 && p <= 500 },
  { key: '$500–1k', test: (p) => p > 500 && p <= 1000 },
  { key: '$1k–2.5k', test: (p) => p > 1000 && p <= 2500 },
  { key: '$2.5k–5k', test: (p) => p > 2500 && p <= 5000 },
  { key: '$5k+', test: (p) => p > 5000 },
];
const PRICE_ORDER = PRICE_BANDS.map((b) => b.key);
function priceBand(p) {
  if (p.quote || p.price == null) return 'Quote-only';
  const b = PRICE_BANDS.find((x) => x.test && x.test(p.price));
  return b ? b.key : 'Quote-only';
}

const IMAGE_ORDER = ['Has image', 'No image'];

function titleCase(s) {
  return String(s || '').replace(/\w[\w'-]*/g, (t) => t.charAt(0).toUpperCase() + t.slice(1).toLowerCase());
}
// Normalize an ai_styles / ai_tags value so "Chinoiserie" and "chinoiserie" collapse to one.
const normLabel = (s) => titleCase(String(s || '').trim());

// Raw-row acquisition — jsonl bundle (self-contained) or live Postgres.
async function fetchRows() {
  if (USE_JSONL) {
    return fs.readFileSync(JSONL, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
  }
  const { Client } = require('pg');
  const client = new Client({ connectionString: DB });
  await client.connect();
  const { rows } = await client.query(`
    SELECT id, mfr_sku, handle, pattern_name, color_name, collection, product_type,
           material, composition, width, roll_length, image_url, gallery_images,
           product_url, description, tags, vendor, price, compare_at_price, quote_only,
           color_hex, dominant_color_hex, ai_colors, ai_styles, ai_tags, ai_description,
           ai_accepted_at, crawled_at, vendor_created_at, updated_at
      FROM fromental_catalog
     ORDER BY (vendor_created_at IS NULL), vendor_created_at DESC NULLS LAST, id DESC`);
  await client.end();
  return rows;
}

async function load() {
  const rows = await fetchRows();

  const products = rows.map((r) => {
    const hex = r.dominant_color_hex || r.color_hex || null;
    // color families: primary hex family + every ai_colors hex family (so a pattern with a
    // green ground and blue motif shows under both Green and Blue).
    const famSet = new Set();
    const pf = hexToFamily(hex); if (pf) famSet.add(pf);
    for (const c of (Array.isArray(r.ai_colors) ? r.ai_colors : [])) {
      const f = hexToFamily(c && c.hex); if (f) famSet.add(f);
    }
    const price = r.price != null && r.price !== '' ? Number(r.price) : null;
    const quote = (!!r.quote_only && price == null) || price == null;
    const styles = [...new Set((Array.isArray(r.ai_styles) ? r.ai_styles : []).map(normLabel).filter(Boolean))];
    const aiTags = [...new Set((Array.isArray(r.ai_tags) ? r.ai_tags : []).map(normLabel).filter(Boolean))];
    const material = r.material || r.composition || null;   // fall back composition → material
    return {
      id: r.id,
      sku: r.mfr_sku,
      handle: r.handle,
      pattern: r.pattern_name,
      color: r.color_name,
      collection: r.collection,
      type: r.product_type,
      material,
      composition: r.composition,
      width: r.width,
      length: r.roll_length,
      image: r.image_url,
      gallery: r.gallery_images || [],
      productUrl: r.product_url,
      description: r.description || r.ai_description || null,
      tags: r.tags || [],
      vendor: (r.vendor || 'Fromental').replace(/\s*Design$/i, ''),
      price,
      quote,
      hex,
      families: [...famSet],
      styles,
      aiTags,
      imageState: r.image_url ? 'Has image' : 'No image',
      enriched: !!r.ai_accepted_at,
      createdAt: r.vendor_created_at || r.crawled_at,
    };
  });
  // precompute priceBand once per product
  for (const p of products) p.priceBand = priceBand(p);

  SNAP = { products, count: products.length };
  LAST_REFRESH = new Date().toISOString();
  console.log(`[fromental] loaded ${products.length} products (${products.filter((p) => p.enriched).length} enriched, source=${USE_JSONL ? 'jsonl' : 'pg'})`);
}

// ── filtered-set + facet aggregation (mode-agnostic; runs over the in-memory SNAP) ──
// Each facet field is a multi-select OR within the field, AND across fields — exactly like
// all.dw. When tallying a given field, that field's own filter is skipped (drill-down counts).
function parseSet(v) { return new Set(String(v || '').split(',').map((s) => s.trim()).filter(Boolean)); }

function applyFilters(products, f, skip) {
  return products.filter((p) => {
    if (f.q) {
      const hay = [p.pattern, p.color, p.sku, p.type, p.collection].filter(Boolean).join(' ').toLowerCase();
      if (!hay.includes(f.q)) return false;
    }
    if (skip !== 'type' && f.types.size && !f.types.has(p.type)) return false;
    if (skip !== 'collection' && f.collections.size && !f.collections.has(p.collection)) return false;
    if (skip !== 'color' && f.colors.size && !p.families.some((x) => f.colors.has(x))) return false;
    if (skip !== 'style' && f.styles.size && !p.styles.some((x) => f.styles.has(x))) return false;
    if (skip !== 'material' && f.materials.size && !f.materials.has(p.material)) return false;
    if (skip !== 'price' && f.prices.size && !f.prices.has(p.priceBand)) return false;
    if (skip !== 'tag' && f.tags.size && !p.aiTags.some((x) => f.tags.has(x))) return false;
    if (skip !== 'image' && f.images.size && !f.images.has(p.imageState)) return false;
    return true;
  });
}
// tally: {value: count} over a set of products for a single-valued or array-valued key.
function tally(products, keyFn) {
  const m = new Map();
  for (const p of products) {
    const vals = keyFn(p);
    for (const v of (Array.isArray(vals) ? vals : [vals])) {
      if (v == null || v === '') continue;
      m.set(v, (m.get(v) || 0) + 1);
    }
  }
  // return a plain object (client sorts by count / order)
  const out = {};
  for (const [k, n] of [...m.entries()].sort((a, b) => b[1] - a[1])) out[k] = n;
  return out;
}

function computeFacets(query) {
  const f = {
    q: String(query.q || '').trim().toLowerCase(),
    types: parseSet(query.types), collections: parseSet(query.collections),
    colors: parseSet(query.colors), styles: parseSet(query.styles),
    materials: parseSet(query.materials), prices: parseSet(query.prices),
    tags: parseSet(query.tags), images: parseSet(query.images),
  };
  const P = SNAP.products;
  return {
    type: tally(applyFilters(P, f, 'type'), (p) => p.type),
    collection: tally(applyFilters(P, f, 'collection'), (p) => p.collection),
    color: tally(applyFilters(P, f, 'color'), (p) => p.families),
    style: tally(applyFilters(P, f, 'style'), (p) => p.styles),
    material: tally(applyFilters(P, f, 'material'), (p) => p.material),
    price_band: tally(applyFilters(P, f, 'price'), (p) => p.priceBand),
    tag: tally(applyFilters(P, f, 'tag'), (p) => p.aiTags),
    image_state: tally(applyFilters(P, f, 'image'), (p) => p.imageState),
    family_order: FAMILY_ORDER,
    family_swatch: FAMILY_SWATCH,
    price_order: PRICE_ORDER,
    image_order: IMAGE_ORDER,
    matched: applyFilters(P, f).length,
    total: P.length,
  };
}

// health probe BEFORE auth (deploy smoke + uptime monitors) — bare liveness only.
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.set('WWW-Authenticate', 'Basic realm="Fromental (Designer Wallcoverings internal)"');
  return res.status(401).send('Authentication required.');
});

app.use(express.json({ limit: '2mb' }));

app.get('/api/meta', (_req, res) => res.json({
  lastRefresh: LAST_REFRESH, intervalSec: REFRESH_INTERVAL_SEC,
  count: SNAP.count, enriched: SNAP.products.filter((p) => p.enriched).length,
  source: USE_JSONL ? 'jsonl' : 'pg', now: new Date().toISOString(),
}));
// light list — drop heavy fields the grid never renders
app.get('/api/products', (_req, res) => res.json({
  count: SNAP.count,
  products: SNAP.products.map(({ gallery, description, composition, ...p }) => p),
}));
// facets recomputed from the current filtered set (query-param driven, like all.dw)
app.get('/api/facets', (req, res) => res.json(computeFacets(req.query)));
app.get('/api/product/:sku', (req, res) => {
  const p = SNAP.products.find((x) => x.sku === req.params.sku);
  if (!p) return res.status(404).json({ error: 'not found' });
  res.json(p);
});

app.use(express.static(path.join(__dirname, 'public')));

// ---- Internal purchasing requests: Request Memo / Check Stock / Get Price ----
// Same 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). Fromental is a
// quote-only bespoke line with no vendor email on file, so requests log + prompt to call.
// express.json() is already mounted above (line ~272), so no duplicate here.
require('./lib/vendor-requests').mountVendorRequests(app, {
  vendorCode: 'fromental',
  getVendor: () => ({ name: 'Fromental' }),
  dataDir: __dirname + '/data',
});

load().then(() => {
  // Only poll for refreshes against a live DB; the jsonl bundle is static.
  if (!USE_JSONL) setInterval(() => load().catch((e) => console.error('[fromental] reload failed:', e.message)), REFRESH_INTERVAL_SEC * 1000);
  app.listen(PORT, () => console.log(`Fromental internal viewer → http://127.0.0.1:${PORT}  (basic-auth ${AUTH_USER}, source=${USE_JSONL ? 'jsonl' : 'pg'})`));
}).catch((e) => { console.error('initial load failed:', e); process.exit(1); });