← back to Schumacher Internal

scripts/build-products-json.js

207 lines

#!/usr/bin/env node
/**
 * INTERNAL Schumacher line viewer data — build data/products.json from dw_unified.shopify_products
 * (vendor ILIKE '%schumacher%'). Schumacher is INTERNAL-ONLY (Steve HARD RULE 2026-07-16): the
 * whole line is archived off the live storefront and lives ONLY here. Self-contained internal PDPs;
 * NO shopify.com / schumacher.com URLs emitted. $0 (local PG read). Facets derived from product_type
 * (book) + AI-Analyzed tags (color bucket + style). Includes the shopify `status` per row as an
 * internal signal (active-was / archived / draft). DELETED_FROM_SHOPIFY + image-less rows are dropped.
 */
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');

const OUT = path.join(__dirname, '..', 'data', 'products.json');

// Facet slice limits
const MAX_SERIES = 300;
const MAX_STYLES =  60;

const PW = (() => {
  const env = fs.readFileSync(require('os').homedir() + '/Projects/secrets-manager/.env', 'utf8');
  const m = env.match(/^DW_ADMIN_DB_PASSWORD=(.*)$/m);
  return m ? m[1].replace(/^["']|["']$/g, '').trim() : (process.env.PGPASSWORD || '');
})();
const pool = new Pool({ host: '127.0.0.1', port: 5432, user: 'dw_admin', database: 'dw_unified', password: PW });

const BUCKET = {
  white: 'white', ivory: 'white', cream: 'white', beige: 'white', alabaster: 'white', oatmeal: 'white',
  grey: 'grey', gray: 'grey', silver: 'grey', neutral: 'grey', taupe: 'grey', greige: 'grey', dove: 'grey',
  black: 'black', charcoal: 'black', ebony: 'black', 'midnight blue': 'blue', midnightblue: 'blue',
  red: 'red', burgundy: 'red', garnet: 'red', crimson: 'red', paprika: 'orange',
  orange: 'orange', rust: 'orange', copper: 'orange', terracotta: 'orange', coral: 'orange', peach: 'orange',
  brown: 'brown', tan: 'brown', bronze: 'brown', chocolate: 'brown', walnut: 'brown',
  gold: 'gold', yellow: 'gold', mustard: 'gold', ochre: 'gold', 'soft gold': 'gold', amber: 'gold',
  green: 'green', olive: 'green', sage: 'green', emerald: 'green', celadon: 'green', 'sage green': 'green', 'sea green': 'green', 'light green': 'green',
  teal: 'teal', turquoise: 'teal', aqua: 'teal',
  blue: 'blue', navy: 'blue', indigo: 'blue', "robin's egg": 'blue', cobalt: 'blue',
  purple: 'purple', violet: 'purple', lavender: 'purple', plum: 'purple', aubergine: 'purple',
  pink: 'pink', rose: 'pink', blush: 'pink', magenta: 'pink', 'pale peach': 'orange',
};
const BUCKET_ORDER = ['white','grey','black','pink','red','orange','brown','gold','green','teal','blue','purple'];
const BUCKET_HUE   = { red:0, orange:30, gold:50, green:120, teal:175, blue:215, purple:280, pink:330, brown:25, white:null, grey:null, black:null };

// Known interior-design style vocabulary (drives the Style facet + sort). Only these tags count as styles.
const STYLES = new Set(['traditional','contemporary','modern','transitional','geometric','floral','botanical',
  'chinoiserie','damask','stripe','striped','ikat','toile','trellis','arts & crafts','art deco','minimalist',
  'english country','scenic','abstract','animal','texture','grasscloth','moiré','plaid','paisley','ogee',
  'medallion','mid-century','bohemian','coastal','tropical','novelty','metallic','ombre','check','herringbone']);

const clean = s => (s == null ? s : String(s)
  .replace(/\bWallpapers\b/gi, 'Wallcoverings').replace(/\bWallpaper\b/gi, 'Wallcovering'));
const cap = s => String(s || '').replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()).trim();

// normalize the messy product_type strings into one clean "book" (collection) chip
function bookFor(pt) {
  const t = (pt || '').toLowerCase();
  if (t.includes('memo') || t.includes('sample')) return 'Memo Sample';
  if (t.includes('trim'))    return 'Trim';
  if (t.includes('fabric'))  return 'Fabric';
  if (t.includes('pillow'))  return 'Pillow';
  return 'Wallcovering';
}

function parseTags(raw) {
  if (!raw) return [];
  const s = String(raw).trim();
  if (s.startsWith('{') && s.endsWith('}')) {           // pg array text form {"a","b"}
    return s.slice(1, -1).split(',').map(t => t.replace(/^"|"$/g, '').trim()).filter(Boolean);
  }
  return s.split(',').map(t => t.trim()).filter(Boolean); // csv form
}

function parseMetafields(raw) {
  try { return typeof raw === 'string' ? JSON.parse(raw) : (raw || {}); } catch { return {}; }
}

function colorFromTags(tags) {
  // prefer an explicit "color:Xyz" structured tag, else first bucket-matching tag
  let name = null, bucket = null;
  for (const t of tags) {
    const m = /^color:(.+)$/i.exec(t);
    if (m) { name = cap(m[1]); const b = BUCKET[m[1].toLowerCase().trim()]; if (b) bucket = b; }
  }
  for (const t of tags) {
    const key = t.toLowerCase().trim();
    if (BUCKET[key]) { bucket = bucket || BUCKET[key]; name = name || cap(t); if (name && bucket) break; }
  }
  return { name, bucket };
}

function styleFromTags(tags) {
  const out = [];
  for (const t of tags) { if (STYLES.has(t.toLowerCase().trim())) out.push(cap(t)); }
  return [...new Set(out)];
}

/** Run a query with a no-rows fallback on error (e.g. missing table in some envs). */
async function querySafe(q, params) {
  return (await pool.query(q, params).catch(() => ({ rows: [] }))).rows[0] || {};
}

/** Increment a facet counter. */
function tally(obj, key) { if (key) obj[key] = (obj[key] || 0) + 1; }

async function main() {
  const { rows } = await pool.query(`
    SELECT dw_sku, mfr_sku, handle, title, product_type, body_html, tags, metafields,
           image_url, status, created_at_shopify AS created_at
    FROM shopify_products
    WHERE vendor ILIKE '%schumacher%'
      AND image_url IS NOT NULL AND image_url <> ''
      AND COALESCE(upper(status),'') <> 'DELETED_FROM_SHOPIFY'
    ORDER BY title, dw_sku
  `);

  // vendor ops meta for the internal PDP (Call Vendor / Our Account #). Defaults are fine if absent.
  const vr = await querySafe(
    `SELECT vendor_name, vendor_discount_pct, pricing_unit, pricing_model, pricing_notes, sample_price
     FROM vendor_registry WHERE vendor_code = $1`, ['schumacher']);
  const fm = await querySafe(
    `SELECT phone, email_1, account_num FROM fmpro
     WHERE vendor_name ILIKE $1 AND account_num IS NOT NULL LIMIT 1`,
    ['%schumacher%']);
  const vendorMeta = {
    name: vr.vendor_name || 'Schumacher',
    phone: fm.phone || null, email: fm.email_1 || null, account_number: fm.account_num || null,
    discount_pct: vr.vendor_discount_pct != null ? Number(vr.vendor_discount_pct) : 15, // Schumacher trade = 15%
    pricing_unit: vr.pricing_unit || null, pricing_model: vr.pricing_model || 'quote',
    pricing_notes: vr.pricing_notes || 'Schumacher is internal-only; price/stock via vendor request.',
    sample_price: vr.sample_price != null ? Number(vr.sample_price) : 4.25,
  };

  const products = [];
  const facets = { books: {}, series: {}, colors: {}, styles: {}, statuses: {} };

  for (const r of rows) {
    const tags = parseTags(r.tags);
    const mf = parseMetafields(r.metafields);
    const patternName = mf?.custom?.pattern_name?.value
      || (r.title || '').split(/\s*[|—-]\s*/)[0].trim() || null;
    const { name: color, bucket } = colorFromTags(tags);
    const styles = styleFromTags(tags);
    const book = bookFor(r.product_type);

    products.push({
      handle: `${r.handle || 'schu'}--${(r.dw_sku || '').toLowerCase()}`,
      dw_sku: r.dw_sku,
      sku: (r.mfr_sku || '').split('__')[0] || null,
      title: clean(r.title),
      display_eyebrow: clean(patternName || ''),
      display_name: clean(r.title),
      series: clean(patternName) || null,
      color: color || null,
      book,
      style: styles[0] || null,
      styles,
      color_bucket: bucket,
      hue: bucket ? BUCKET_HUE[bucket] : null,
      hex: null, sat: null, val: bucket === 'white' ? 1 : bucket === 'black' ? 0 : 0.5,
      width: mf?.sec?.width?.value || mf?.custom?.width?.value || null,
      length: null,
      repeat: mf?.sec?.repeat?.value || null,
      material: mf?.custom?.material?.value || mf?.sec?.material?.value || null,
      match: null,
      price: null, cost: null, price_code: null,
      body_html: clean(r.body_html || ''),
      status: r.status || null,                         // internal signal: ACTIVE(was)/ARCHIVED/DRAFT
      swatch: r.image_url, room: r.image_url, images: [r.image_url],
      published_at: r.created_at,
      inquiry_sku: r.dw_sku,
    });

    tally(facets.books, book);
    tally(facets.series, clean(patternName) || null);
    tally(facets.colors, bucket);
    for (const s of styles) tally(facets.styles, s);
    tally(facets.statuses, r.status || null);
  }

  const orderedColors = BUCKET_ORDER.filter(b => facets.colors[b]).map(b => [b, facets.colors[b]]);

  const snapshot = {
    built_at: new Date().toISOString(),
    source: 'dw_unified.shopify_products vendor~Schumacher (INTERNAL-ONLY — archived off Shopify)',
    count: products.length,
    vendor: vendorMeta,
    facets: {
      total: products.length,
      books:    Object.entries(facets.books).sort((a, b) => b[1] - a[1]),
      series:   Object.entries(facets.series).sort((a, b) => b[1] - a[1]).slice(0, MAX_SERIES),
      colors:   orderedColors,
      styles:   Object.entries(facets.styles).sort((a, b) => b[1] - a[1]).slice(0, MAX_STYLES),
      statuses: Object.entries(facets.statuses).sort((a, b) => b[1] - a[1]),
    },
    products,
  };
  fs.writeFileSync(OUT, JSON.stringify(snapshot));
  console.log(`products.json -> ${OUT}`);
  console.log(`  products: ${products.length}`);
  console.log(`  books: ${snapshot.facets.books.map(b => b[0] + ':' + b[1]).join(', ')}`);
  console.log(`  colors: ${orderedColors.map(c => c[0] + ':' + c[1]).join(', ')}`);
  console.log(`  styles: ${snapshot.facets.styles.slice(0, 8).map(s => s[0] + ':' + s[1]).join(', ')}`);
  console.log(`  statuses: ${snapshot.facets.statuses.map(s => s[0] + ':' + s[1]).join(', ')}`);
  await pool.end();
}
main().catch(e => { console.error(e); process.exit(1); });