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