← back to Gracie Internal
server.js
212 lines
#!/usr/bin/env node
/**
* Gracie INTERNAL line viewer — dw_unified.gracie_catalog, read-only browse/curate.
* Basic-auth gated (admin / DW2024!). Reads Postgres DIRECTLY (no JSON export).
* Internal spec (astek-landing :9944 family):
* - left panel: search + one collapsible field tab per column (values + counts, click-to-filter)
* - density slider (card min-width), image-only toggle, created-date chips
* - location.origin fetches, no <a>-wrapped cards
* QUOTE-ONLY line — no prices, no checkout. Purely a curation/browse surface.
*/
const express = require('express');
const path = require('path');
const fs = require('fs');
const PORT = process.env.PORT || 10073;
const PG = process.env.PG || 'postgresql://localhost/dw_unified';
// AUTO-DETECT data source: self-contained jsonl bundle (no DB — Kamatera) vs live
// Postgres (local). jsonl mode when DATA_SOURCE=jsonl OR the bundle exists.
const JSONL = path.join(__dirname, 'data', 'gracie.jsonl');
const USE_JSONL = process.env.DATA_SOURCE === 'jsonl' || (process.env.DATA_SOURCE !== 'pg' && fs.existsSync(JSONL));
const app = express();
app.use(require('compression')());
// Unauthenticated health probe BEFORE the auth gate — bare liveness only, no catalog
// size/detail leaked to the open probe (count stays behind auth via /api/facets).
app.get('/healthz', (_req, res) => res.json({ ok: true }));
const [AUTH_USER, AUTH_PASS] = (process.env.BASIC_AUTH || 'admin:DW2024!').split(':');
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="Gracie (internal)"');
return res.status(401).send('Authentication required.');
});
let SNAP = [];
let LAST_REFRESH = new Date().toISOString();
// Raw-row acquisition — jsonl bundle (self-contained) or live Postgres. JSONL rows
// are the exact pg row shape so the downstream mapping is unchanged.
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 c = new Client({ connectionString: PG });
await c.connect();
const { rows } = await c.query(`
SELECT mfr_sku, pattern_name, color_name, collection, product_type, material,
description, ai_description, image_url, all_images, product_url,
color_hex, dominant_color_hex, ai_styles, ai_tags, ai_colors,
settlement_flag, on_shopify, dw_sku, crawled_at, last_scraped, ai_accepted_at
FROM gracie_catalog ORDER BY collection, pattern_name`);
await c.end();
return rows;
}
async function load() {
const rows = await fetchRows();
SNAP = rows.map((r) => {
const gallery = (r.all_images || '').split('|').filter(Boolean);
let styles = []; let tags = []; let colors = [];
try { styles = Array.isArray(r.ai_styles) ? r.ai_styles : JSON.parse(r.ai_styles || '[]'); } catch {}
try { tags = Array.isArray(r.ai_tags) ? r.ai_tags : JSON.parse(r.ai_tags || '[]'); } catch {}
try { colors = Array.isArray(r.ai_colors) ? r.ai_colors : JSON.parse(r.ai_colors || '[]'); } catch {}
return {
mfr_sku: r.mfr_sku,
pattern_name: r.pattern_name,
color_name: r.color_name || (colors[0] && colors[0].name) || '',
collection: r.collection || '',
product_type: r.product_type || '',
material: r.material || '',
description: r.ai_description || r.description || '',
image_url: r.image_url || gallery[0] || '',
gallery,
product_url: r.product_url || '',
color_hex: r.color_hex || r.dominant_color_hex || null,
styles, tags, colors,
settlement_flag: r.settlement_flag || null,
enriched: !!r.ai_accepted_at,
on_shopify: !!r.on_shopify,
dw_sku: r.dw_sku || null,
// pg returns Date objects; jsonl returns ISO strings — normalize both.
created_at: new Date(r.crawled_at || r.last_scraped || Date.now()).toISOString(),
};
});
LAST_REFRESH = new Date().toISOString();
console.log(`[gracie-internal] loaded ${SNAP.length} products`);
}
// ── color-family bucketing (all.dw rail parity) ──────────────────────────────────
// Gracie is single-vendor and has no free-text color TAGS like all.dw — instead each
// product carries a real HEX palette (color_hex / dominant_color_hex + ai_colors[{hex,name}]).
// We bucket every hex into an interior-designer hue FAMILY (hex → HSL → family), so the
// Color facet shows family rows with a representative swatch + count. Bucketing is derived
// on SNAP, so it is identical in both data modes (pg + jsonl) by construction.
const FAMILY_ORDER = ['Red', 'Orange', 'Brown', 'Yellow', 'Gold', 'Green', 'Teal', 'Blue', 'Purple', 'Pink', 'Beige', 'Cream', 'Black', 'Gray', 'White', 'Metallic', 'Multi'];
function hexToRgb(hex) {
const m = String(hex || '').trim().replace(/^#/, '');
if (!/^[0-9a-fA-F]{6}$/.test(m)) return null;
return [parseInt(m.slice(0, 2), 16), parseInt(m.slice(2, 4), 16), parseInt(m.slice(4, 6), 16)];
}
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255;
const mx = Math.max(r, g, b), mn = Math.min(r, g, b); let h = 0, s = 0; const l = (mx + mn) / 2;
if (mx !== mn) {
const d = mx - mn; s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
switch (mx) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; case b: h = (r - g) / d + 4; break; }
h /= 6;
}
return [h * 360, s * 100, l * 100];
}
// hex → interior-designer color family; null when unparseable.
function familyOf(hex) {
const rgb = hexToRgb(hex); if (!rgb) return null;
const [h, s, l] = rgbToHsl(...rgb);
if (l <= 10) return 'Black';
if (l >= 94 && s <= 8) return 'White';
if (s <= 10) { if (l >= 86) return 'White'; return 'Gray'; }
if (s <= 25 && l >= 84) return 'Cream'; // parchment/ivory grounds
if ((h < 52 || h >= 345) && l <= 32 && !(h > 300 && h < 345)) return 'Brown'; // espresso/chocolate
if (h >= 18 && h < 52) { // warm band → Brown/Beige/Gold/Cream
if (l <= 42) return 'Brown';
if (s <= 38 && l >= 82) return 'Cream';
if (s <= 55 && l >= 60) return 'Beige';
if (s >= 45 && l >= 42 && l <= 72) return 'Gold';
if (l <= 60) return 'Brown';
return 'Beige';
}
if (h >= 52 && h < 66) return 'Yellow';
if ((h < 15 || h >= 340) && (l >= 72 || (s <= 60 && l >= 60))) return 'Pink';
if (h < 15 || h >= 345) return 'Red';
if (h < 18) return 'Orange';
if (h < 160) return 'Green';
if (h < 200) return 'Teal';
if (h < 255) return 'Blue';
if (h < 290) return 'Purple';
if (h < 340) return 'Pink';
return 'Red';
}
// The set of color families present on a product (deduped), from its ai_colors palette,
// falling back to color_hex when the palette is empty.
function familiesOf(p) {
const fams = new Set();
for (const c of (p.colors || [])) { const f = familyOf(c && c.hex); if (f) fams.add(f); }
if (!fams.size && p.color_hex) { const f = familyOf(p.color_hex); if (f) fams.add(f); }
return [...fams];
}
// ── facet aggregation (all.dw rail shape) ────────────────────────────────────────
// Returns {counts:{value:count}} per dimension + swatch map for color + orderings.
// Computed on SNAP, so pg and jsonl modes yield identical facets. Style + Tag are
// EXPLODED from their JSONB arrays; Color is family-bucketed; Price is quote-only.
function facets() {
const type = {}, series = {}, color = {}, style = {}, material = {}, tag = {}, price_band = {}, image_state = {};
const colorSwatch = {};
const bump = (o, v) => { if (v == null || v === '') return; o[v] = (o[v] || 0) + 1; };
for (const p of SNAP) {
bump(type, p.product_type);
bump(series, p.collection);
bump(material, p.material || 'Unspecified');
(p.styles || []).forEach((s) => bump(style, s)); // explode ai_styles JSONB
(p.tags || []).forEach((t) => bump(tag, t)); // explode ai_tags JSONB
for (const fam of familiesOf(p)) {
bump(color, fam);
// a representative swatch per family — prefer a hex that truly buckets to this family
const hit = (p.colors || []).find((c) => c && familyOf(c.hex) === fam) || (familyOf(p.color_hex) === fam ? { hex: p.color_hex } : null);
if (hit && (!colorSwatch[fam] || familyOf(colorSwatch[fam]) !== fam)) colorSwatch[fam] = hit.hex;
}
// Gracie is QUOTE-ONLY — a single price band reflects that (parity with all.dw's price facet shape).
bump(price_band, 'Quote-only');
bump(image_state, p.image_url ? 'Has image' : 'No image');
}
return {
type, series, color, style, material, tag, price_band, image_state,
color_swatch: colorSwatch,
family_order: FAMILY_ORDER,
price_order: ['Quote-only'],
total: SNAP.length,
refreshed: LAST_REFRESH,
};
}
app.get('/api/products', (_req, res) => res.json({ count: SNAP.length, products: SNAP }));
app.get('/api/facets', (_req, res) => res.json(facets()));
app.use(express.static(path.join(__dirname, 'public')));
// ── Internal purchasing requests: Request Memo / Check Stock / Get Price ──────────
// 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). Gracie 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: 'gracie',
getVendor: () => ({ name: 'Gracie' }),
dataDir: __dirname + '/data',
});
load().then(() => {
app.listen(PORT, () => console.log(`Gracie internal viewer → http://127.0.0.1:${PORT} (source=${USE_JSONL ? 'jsonl' : 'pg'})`));
// hot-refresh every 10 min so new scrapes/enrichment appear without restart —
// only against a live DB; the jsonl bundle is static.
if (!USE_JSONL) setInterval(() => load().catch((e) => console.error('[gracie-internal] reload:', e.message)), 600000);
}).catch((e) => { console.error('[gracie-internal] load failed', e); process.exit(1); });