← back to Fentucci Naturals

server.js

186 lines

// Fentucci Naturals internal line viewer — basic-auth, reads dw_unified.tokiwa_catalog.
// Port 9981 (Mac2 local). Internal curation surface per the internal-line-viewer spec
// (crezana-internal / astek-landing reference). QUOTE-ONLY line, product_type Wallcovering.
// INTERNAL ONLY: mfr_sku (Tokiwa) is visible here — that is fine per the
// internal-line-viewer precedent (basic-auth admin surface); the real vendor name is
// NEVER customer-facing anywhere else.
//
// Catalog is loaded ONCE into memory from local PG (host=/tmp), each row enriched
// (color family from color_name/color_hex, tag fold, image path from image_local),
// re-loaded every 15 min. Images served from the LOCAL ./images dir (images-always-local).
const express = require('express');
const basicAuth = require('basic-auth');
const compression = require('compression');
const path = require('path');

const PORT = process.env.PORT || 9981;
const [AUTH_USER, AUTH_PASS] = (process.env.BASIC_AUTH || 'admin:DW2024!').split(':');

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

// ---- unauthenticated /healthz FIRST (hard rule: the fleet keepalive must see a
// plain 200, or it misreads the healthy 401 as dead and restart-loops the process) ----
app.get('/healthz', (_req, res) => res.status(200).json({ ok: true, service: 'fentucci-viewer', count: ROWS.length }));

// ---- Basic auth (admin / DW2024!) for everything else ----
app.use((req, res, next) => {
  const cred = basicAuth(req);
  if (!cred || cred.name !== AUTH_USER || cred.pass !== AUTH_PASS) {
    res.set('WWW-Authenticate', 'Basic realm="Fentucci Naturals Internal"');
    return res.status(401).send('Auth required');
  }
  next();
});

app.use(express.static(path.join(__dirname, 'public')));
app.use('/images', express.static(path.join(__dirname, 'images'), { maxAge: '1d' }));

// ---- Internal purchasing requests: Memo / Stock / Price (shared module, spec #8).
// Quote-only line, no vendor email on file → requests log + prompt to call. ----
app.use(express.json());
require('./lib/vendor-requests').mountVendorRequests(app, {
  vendorCode: 'fentucci',
  getVendor: () => ({ name: 'Fentucci Naturals' }), // no email on file — log + call
  dataDir: path.join(__dirname, 'data'),
});

// ─── enrichment (in-memory rows) ─────────────────────────────────────────────
const FAMILY_ORDER = ['Red', 'Orange', 'Brown', 'Yellow', 'Gold', 'Green', 'Teal', 'Blue', 'Purple', 'Pink', 'Beige', 'Cream', 'Black', 'Gray', 'White', 'Multi'];
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)',
};
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', mocha: 'Brown', umber: 'Brown', sienna: 'Brown', chestnut: 'Brown', espresso: 'Brown', walnut: 'Brown',
  yellow: 'Yellow', mustard: 'Yellow', lemon: 'Yellow', straw: 'Yellow',
  gold: 'Gold', golden: 'Gold', brass: 'Gold', bronze: 'Gold', champagne: 'Gold',
  green: 'Green', olive: 'Green', sage: 'Green', mint: 'Green', emerald: 'Green', forest: 'Green', moss: 'Green', seagrass: 'Green',
  teal: 'Teal', turquoise: 'Teal', aqua: 'Teal', cyan: 'Teal',
  blue: 'Blue', navy: 'Blue', indigo: 'Blue', cobalt: 'Blue', denim: 'Blue', 'sky blue': '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', natural: 'Beige', wheat: 'Beige', flax: 'Beige', hemp: 'Beige',
  cream: 'Cream', ivory: 'Cream', 'off-white': 'Cream', 'off white': 'Cream', vanilla: 'Cream', linen: 'Cream', alabaster: 'Cream', oatmeal: 'Cream',
  black: 'Black', charcoal: 'Black', ebony: 'Black', onyx: 'Black',
  gray: 'Gray', grey: 'Gray', silver: 'Gray', slate: 'Gray', graphite: 'Gray', pewter: 'Gray', smoke: 'Gray', ash: 'Gray',
  white: 'White', snow: 'White', 'pure white': '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;
  if (d < 26) {
    if (light > 0.90) return 'White';
    if (light > 0.80) return 'Cream';
    if (light > 0.16) return 'Gray';
    return 'Black';
  }
  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;
  if (h < 15 || h >= 345) return 'Red';
  if (h < 45) { 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 rowFamily(r) {
  // color_name word first (e.g. "Pewter" → Gray), else bucket the hex
  const words = String(r.color_name || '').toLowerCase().split(/[\s/]+/);
  for (const w of words) { if (NAME_TO_FAMILY[w]) return NAME_TO_FAMILY[w]; }
  return hexFamily(r.color_hex) || 'Multi';
}
// tags carry plumbing values (dw_sku, display_variant, quotes, brand strings) — keep
// them OUT of the facet but leave the row's raw tags intact for search/audit.
const TAG_NOISE = new Set(['display_variant', 'quotes', 'fentucci naturals', 'fentucci naturals collection', 'natural wallcovering', 'natural fiber', 'wallcovering']);
function cleanTags(r) {
  return (r.tags || []).filter((t) => {
    const lo = String(t).toLowerCase();
    if (TAG_NOISE.has(lo)) return false;
    if (/^dwfn-/i.test(t)) return false;
    if (t === r.pattern_name || t === r.color_name || t === r.material) return false;
    return true;
  });
}
function enrich(r) {
  r.colors = [rowFamily(r)];
  r.tags_clean = cleanTags(r);
  r.image = r.image_local ? '/' + String(r.image_local).replace(/^\/+/, '') : null; // images/<file>.jpg → /images/<file>.jpg
  r.image_state = r.image ? 'Has image' : 'No image';
  r.price_band = 'Quote-only · $4.25 sample';
  return r;
}

// ---- in-memory catalog, reloaded from local PG every 15 min ----
let ROWS = [];
async function load() {
  const { Pool } = require('pg');
  const pool = new Pool({ host: process.env.PGHOST || '/tmp', database: 'dw_unified' });
  const { rows } = await pool.query(`
    SELECT id, dw_sku, mfr_sku, pattern_series, pattern_name, color_name, color_hex,
           brand, product_type, material, source_category, image_local, price_mode,
           sample_price, status, settlement_status, description, tags, specs, created_at, enriched_at
    FROM tokiwa_catalog
    ORDER BY id`);
  await pool.end();
  ROWS = rows.map(enrich);
  console.log(`[fentucci] loaded ${ROWS.length} products from tokiwa_catalog`);
}
setInterval(() => load().catch((e) => console.error('[fentucci] reload failed', e.message)), 15 * 60 * 1000);

// Facet counts — {v,n[,dot]} lists, count desc (color 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 = {}) => {
    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 {
    material: scalar('material'),
    pattern: scalar('pattern_name'),
    series: scalar('pattern_series'),
    category: scalar('source_category'),
    color: listCol('colors', { order: FAMILY_ORDER, dot: true }),
    color_name: scalar('color_name'),
    tags: listCol('tags_clean'),
    settlement: scalar('settlement_status'),
    status: scalar('status'),
    price_band: [{ v: 'Quote-only · $4.25 sample', n: ROWS.length }],
    image_state: scalar('image_state'),
    family_order: FAMILY_ORDER,
  };
}

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 }); }
});

load().then(() => {
  app.listen(PORT, () => console.log(`fentucci-viewer on :${PORT}`));
}).catch((e) => { console.error('[fentucci] load failed', e); process.exit(1); });