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