← back to Dw Catalog 3d

gen.mjs

199 lines

#!/usr/bin/env node
// Generator for the dw-catalog-3d Three.js drill-down.
// READ-ONLY against dw_unified (SELECT only). Writes local JSON into public/.
//   public/scene.json          -> one row per vendor with >=1 ACTIVE product
//   public/v/<slug>.json       -> drill data (collections -> products), ACTIVE+STAGED+DRAFT, each tagged with `st`
//   public/v/_index.json       -> manifest
//
// Usage: node gen.mjs
import { execFileSync } from 'node:child_process';
import { writeFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const ROOT = dirname(fileURLToPath(import.meta.url));
const PUB = join(ROOT, 'public');
const VDIR = join(PUB, 'v');
mkdirSync(VDIR, { recursive: true });

// caps (per brief)
const CAP_PROD_PER_COLL = 80;
const CAP_COLL_PER_VENDOR = 36;
const CAP_PROD_PER_VENDOR = 1400;

const STAGED_STATUSES = ['scheduled', 'drafted', 'manual_hold', 'blocked_metafields'];

function q(sql) {
  // returns array of rows, each an array of column strings. Uses a unit-separator to avoid delim collisions.
  const out = execFileSync('psql', ['-d', 'dw_unified', '-tAF', '\x1f', '-c', sql], {
    encoding: 'utf8', maxBuffer: 1024 * 1024 * 512,
  });
  return out.split('\n').filter(l => l.length).map(l => l.split('\x1f'));
}

function slugify(s) {
  return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}

// Cosmetic only: strip U+FFFD replacement chars left by upstream double-encoding
// (e.g. a corrupted "Coordonné" row whose original bytes are already lost) so the
// local viewer renders a clean string instead of mojibake boxes. Display-side only.
function cleanName(s) {
  return s.replace(/�/g, '').replace(/\s+/g, ' ').trim() || s;
}

function priceStr(retail, price) {
  const v = Number(retail) > 0 ? Number(retail) : (Number(price) > 0 ? Number(price) : 0);
  if (v <= 0) return '';
  return '$' + (Number.isInteger(v) ? v.toString() : v.toFixed(2));
}

function collFromTags(tags) {
  if (!tags) return 'Other / Uncollected';
  // tags is comma-separated; match "Collection: <name>" case-insensitively
  for (const raw of tags.split(',')) {
    const t = raw.trim();
    const m = t.match(/^collection:\s*(.+)$/i);
    if (m) return m[1].trim();
  }
  return 'Other / Uncollected';
}

// ---------------------------------------------------------------------------
// 1. staged dw_skus set (vendor-level staged COUNTS come from product_schedule)
// ---------------------------------------------------------------------------
console.error('querying staged set...');
const stagedRows = q(`
  SELECT lower(coalesce(dw_sku,'')) FROM product_schedule
  WHERE status IN (${STAGED_STATUSES.map(s => `'${s}'`).join(',')})
    AND dw_sku IS NOT NULL AND dw_sku<>''`);
const stagedSkus = new Set(stagedRows.map(r => r[0]).filter(Boolean));

// vendor-level staged counts (by vendor_name from the schedule)
const stagedByVendor = {};
for (const [vn, c] of q(`
  SELECT vendor_name, count(*) FROM product_schedule
  WHERE status IN (${STAGED_STATUSES.map(s => `'${s}'`).join(',')})
    AND vendor_name IS NOT NULL AND vendor_name<>''
  GROUP BY 1`)) {
  stagedByVendor[vn] = Number(c) || 0;
}

// ---------------------------------------------------------------------------
// 2. scene.json — per-vendor status rollup, vendors with >=1 ACTIVE
// ---------------------------------------------------------------------------
console.error('querying vendor rollup...');
const rollup = q(`
  SELECT vendor,
    count(*) FILTER (WHERE upper(status)='ACTIVE'),
    count(*) FILTER (WHERE upper(status)='DRAFT'),
    count(*) FILTER (WHERE upper(status)='ARCHIVED'),
    count(*) FILTER (WHERE upper(status)='DELETED_FROM_SHOPIFY')
  FROM shopify_products
  WHERE vendor IS NOT NULL AND vendor<>''
  GROUP BY vendor
  HAVING count(*) FILTER (WHERE upper(status)='ACTIVE') >= 1
  ORDER BY count(*) FILTER (WHERE upper(status)='ACTIVE') DESC`);

// representative active image per vendor (first non-empty)
console.error('querying vendor rep images...');
const repImg = {};
for (const [vendor, img] of q(`
  SELECT DISTINCT ON (vendor) vendor, image_url
  FROM shopify_products
  WHERE upper(status)='ACTIVE' AND image_url IS NOT NULL AND image_url<>''
  ORDER BY vendor, id`)) {
  repImg[vendor] = img;
}

const slugSeen = new Map();
function uniqueSlug(vendor) {
  let base = slugify(vendor) || 'vendor';
  let s = base, n = 1;
  while (slugSeen.has(s)) { s = `${base}-${++n}`; }
  slugSeen.set(s, true);
  return s;
}

const scene = rollup.map(([vendor, active, draft, archived, deleted]) => {
  const slug = uniqueSlug(vendor);
  return {
    vendor: cleanName(vendor),
    _rawVendor: vendor,
    active: Number(active) || 0,
    staged: stagedByVendor[vendor] || 0,
    draft: Number(draft) || 0,
    archived: Number(archived) || 0,
    deleted: Number(deleted) || 0,
    img: repImg[vendor] || '',
    slug,
  };
});
writeFileSync(join(PUB, 'scene.json'), JSON.stringify(scene.map(({ _rawVendor, ...rest }) => rest)));
console.error(`scene.json: ${scene.length} vendors`);

// ---------------------------------------------------------------------------
// 3. drill files — ACTIVE+STAGED+DRAFT products per vendor, tagged `st`
// ---------------------------------------------------------------------------
const index = {};
let totalDrillProducts = 0;
let drillFiles = 0;

for (const v of scene) {
  const vendor = v.vendor;          // cleaned (display)
  const rawVendor = v._rawVendor;   // raw (for SQL match)
  const vsafe = rawVendor.replace(/'/g, "''");
  // pull ACTIVE+DRAFT products (prefer rows WITH images), capped at CAP_PROD_PER_VENDOR
  const rows = q(`
    SELECT title, coalesce(variant_sku, dw_sku, sku, ''), retail_price, price, handle, image_url, tags,
           upper(status), lower(coalesce(dw_sku, ''))
    FROM shopify_products
    WHERE vendor='${vsafe}' AND upper(status) IN ('ACTIVE','DRAFT')
    ORDER BY (CASE WHEN image_url IS NOT NULL AND image_url<>'' THEN 0 ELSE 1 END),
             (CASE WHEN upper(status)='ACTIVE' THEN 0 ELSE 1 END), id
    LIMIT ${CAP_PROD_PER_VENDOR}`);

  // bucket into collections
  const collMap = new Map(); // name -> {name,count,products:[]}
  for (const [title, sku, retail, price, handle, img, tags, status, dwsku] of rows) {
    if (!title) continue;
    const cname = collFromTags(tags);
    let st = status === 'ACTIVE' ? 'active' : 'draft';
    if (dwsku && stagedSkus.has(dwsku)) st = 'staged';
    if (!collMap.has(cname)) collMap.set(cname, { name: cname, count: 0, products: [] });
    const c = collMap.get(cname);
    c.count++;
    // Preventive 404-guard (catalog-integrity audit 2026-06-26): a product is only a
    // CLICKABLE tile if its handle resolves to a real PDP. Empty / "-handle" / "-sample"
    // placeholder handles are known to 404 on the storefront, so skip them as tiles
    // (still counted above so collection sizes stay truthful). Keeps the panel 404-clean
    // as the daily refresh pulls in new rows.
    const hh = (handle || '').trim();
    const handleOk = hh && !hh.endsWith('-handle') && !hh.endsWith('-sample');
    if (handleOk && c.products.length < CAP_PROD_PER_COLL) {
      c.products.push({
        t: title,
        sku: sku || '',
        p: priceStr(retail, price),
        h: handle || '',
        img: img || '',
        st,
      });
    }
  }

  // collections: cap count, sort by count desc
  let collections = [...collMap.values()].sort((a, b) => b.count - a.count).slice(0, CAP_COLL_PER_VENDOR);
  const total = rows.length;
  const shown = collections.reduce((s, c) => s + c.products.length, 0);
  totalDrillProducts += shown;

  writeFileSync(join(VDIR, `${v.slug}.json`), JSON.stringify({ vendor, total, collections }));
  drillFiles++;
  index[vendor] = { slug: v.slug, collections: collections.length, shown };
}

writeFileSync(join(VDIR, '_index.json'), JSON.stringify(index, null, 2));
console.error(`drill files: ${drillFiles}  total drill products shown: ${totalDrillProducts}`);
console.error('DONE');