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