← back to Greenland Onboard

scripts/build-viewer-snapshot.mjs

175 lines

#!/usr/bin/env node
// build-viewer-snapshot.mjs — merge greenland_catalog + enriched-cork.json + shopify mirror
// into ONE internal-viewer payload (public/viewer.json). $0 local. No hotlinks (images local).
//
//   node scripts/build-viewer-snapshot.mjs
//
// Sources:
//   - dw_unified.greenland_catalog     : canonical specs, gl_id (live-stock key), price, status, created
//   - data/enriched-cork.json          : hex, topColors[], colorFamily (hue bucket), city (style), tags
//   - dw_unified.shopify_products      : handle, shopify_id (Shopify links), live status  [keyed Cork-<n>-Sample]
//
// Pricing reality (per greenland-scraper-manager skill + vendor_registry):
//   RETAIL is canonical $59.50/yd, 6-yd increments. NET COST is NOT yet known — Greenland
//   wholesale cost has not been supplied and vendor_registry.vendor_discount_pct is blank/unconfirmed.
//   So net cost / discount / margin are surfaced HONESTLY as "unconfirmed" — never invented.

import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
const OUT = path.join(ROOT, 'public', 'viewer.json');
const ENRICHED = path.join(ROOT, 'data', 'enriched-cork.json');

const DB = (process.env.DATABASE_URL ||
  'postgresql:///dw_unified?host=/tmp&user=stevestudio2');

function psql(sql) {
  const out = execFileSync('psql', [DB, '-tAc', sql], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
  return out.trim();
}
// JSON aggregate straight out of PG for the catalog + shopify mirror.
function psqlJson(sql) {
  const raw = psql(sql);
  return raw ? JSON.parse(raw) : [];
}

// ── 1) canonical catalog rows ──────────────────────────────────────────────
const catalog = psqlJson(`
  SELECT COALESCE(json_agg(row_to_json(t)), '[]')
  FROM (
    SELECT
      dw_sku, dw_number, gl_id,
      (raw->>'parent_id')::int AS gl_product_id,   -- parent product id = live-stock key
      mfr_sku, pattern_name, color_name, collection,
      material, use, width, width_inches, backing, composition, pattern_repeat,
      fire_rating_us, weight, minimum_order, category_name, subcategory_names,
      product_type, product_url, description,
      price_per_yard, min_increment_yards, sold_per, status,
      first_seen_at, is_publish
    FROM greenland_catalog
    WHERE dw_sku LIKE 'Cork-%'
    ORDER BY dw_number
  ) t`);

// ── 2) shopify mirror (handle / shopify_id / live status) keyed by base Cork- SKU ──
const shopRows = psqlJson(`
  SELECT COALESCE(json_agg(row_to_json(t)), '[]')
  FROM (
    SELECT
      regexp_replace(sku,'-Sample$','') AS base_sku,
      handle,
      split_part(shopify_id,'/',5) AS shopify_pid,
      status AS shop_status,
      online_store_published,
      min_variant_price AS sample_price
    FROM shopify_products
    WHERE sku LIKE 'Cork-5%-Sample'
  ) t`);
const shopBy = new Map(shopRows.map(r => [r.base_sku, r]));

// ── 3) local enrichment (hex, palette, colorFamily/hue, city/style, tags) ──
const enrichedArr = JSON.parse(fs.readFileSync(ENRICHED, 'utf8'));
const enrBy = new Map(enrichedArr.map(e => [e.dwSku, e]));

// Vendor/pricing facts (single source of truth for the pricing card).
const RETAIL_PER_YARD = 59.50;
const MIN_INCREMENT = 6;
const SAMPLE_PRICE = 4.25;

function localImage(dwSku) {
  const rel = `img/${dwSku}.jpg`;
  return fs.existsSync(path.join(ROOT, 'public', rel)) ? '/' + rel : null;
}

const products = catalog.map(c => {
  const enr = enrBy.get(c.dw_sku) || {};
  const shop = shopBy.get(c.dw_sku) || {};
  const retail = c.price_per_yard != null ? Number(c.price_per_yard) : RETAIL_PER_YARD;
  const step = c.min_increment_yards || MIN_INCREMENT;

  return {
    // identity
    dw_sku: c.dw_sku,
    dw_number: c.dw_number,
    gl_id: c.gl_id,                       // SKU-level id (reference)
    gl_product_id: c.gl_product_id,       // parent product id → live-stock re-check key
    mfr_sku: (c.mfr_sku || enr.mfrSku || '').trim(),
    mfr_model: enr.mfrModel || null,
    // naming (customer-facing = Phillipe Romano; city = style)
    pattern: c.pattern_name || enr.rawColor || null,
    color: enr.cleanColor || c.color_name || null,
    city: enr.city || null,              // coastal-city STYLE
    collection: c.collection || enr.collectionName || null,
    collection_code: enr.collectionCode || null,
    title: enr.title || null,
    // specs
    material: c.material || enr.material || 'Cork',
    use: c.use || enr.use || null,
    width: c.width || enr.width || null,
    width_inches: c.width_inches != null ? Number(c.width_inches) : null,
    backing: c.backing || enr.backing || null,
    composition: c.composition || null,
    repeat: c.pattern_repeat || enr.repeat || null,
    fire_rating: c.fire_rating_us || enr.fireRating || null,
    weight: c.weight || enr.weight || null,
    minimum_order: c.minimum_order || enr.minimumOrder || null,
    country: c.category_name ? null : null, // country-of-origin not in feed; left null
    category: c.category_name || null,
    subcategory: c.subcategory_names || null,
    product_type: c.product_type || 'Wallcovering',
    description: enr.description || c.description || null,
    // color / hue
    hex: enr.hex || null,
    top_colors: enr.topColors || null,
    color_family: enr.colorFamily || null,   // hue bucket
    tags: enr.tags || null,
    // image (LOCAL ONLY — never hotlink)
    image: localImage(c.dw_sku),
    // pricing (internal): retail canonical, cost/discount honestly unconfirmed
    retail_per_yard: retail,
    min_increment_yards: step,
    sold_per: c.sold_per || 'Yard',
    sample_price: SAMPLE_PRICE,
    net_cost: null,                        // Greenland wholesale not supplied
    trade_discount_pct: null,              // vendor_registry unconfirmed
    cost_status: 'unconfirmed',            // drives the pricing card badge
    // stock (snapshot from feed's isPublish; live re-check via /api/stock)
    in_catalog: c.is_publish === 1 || c.is_publish === true,
    stock_snapshot: (c.is_publish === 1 || c.is_publish === true) ? 'in_catalog' : 'unlisted',
    // status + links
    status: c.status || 'active',
    shop_status: shop.shop_status || null,
    online_published: shop.online_store_published === true || shop.online_store_published === 't',
    handle: shop.handle || null,
    shopify_pid: shop.shopify_pid || null,
    vendor_url: c.product_url || enr.productUrl || null,
    created_at: c.first_seen_at || null,
  };
});

const facetFields = ['collection', 'color_family', 'material', 'use', 'width', 'fire_rating', 'backing', 'composition', 'city'];
const snapshot = {
  generated_at: new Date().toISOString(),
  vendor_internal: 'Greenland',           // internal-only label
  brand_public: 'Phillipe Romano',
  line: 'Cork',
  retail_per_yard: RETAIL_PER_YARD,
  min_increment_yards: MIN_INCREMENT,
  sample_price: SAMPLE_PRICE,
  count: products.length,
  facet_fields: facetFields,
  products,
};

fs.writeFileSync(OUT, JSON.stringify(snapshot));
const withImg = products.filter(p => p.image).length;
const withHex = products.filter(p => p.hex).length;
const withShop = products.filter(p => p.handle).length;
console.log(`[snapshot] ${products.length} cork SKUs → ${OUT}`);
console.log(`[snapshot] local images ${withImg}/${products.length} · hue/hex ${withHex} · shopify-linked ${withShop}`);
console.log(`[snapshot] pricing: retail $${RETAIL_PER_YARD}/yd (min ${MIN_INCREMENT}yd) · net cost=unconfirmed · discount=unconfirmed`);