← back to Hollywood Import

hollywood-create.mjs

162 lines

// Create genuinely-absent Hollywood products on Shopify — officer-gated, sample-SAFE by
// construction (both variants in ONE POST /products.json → no productVariantsBulkCreate, so
// the sample-overwrite bug cannot fire). Then go-live: track→activate→set 2026→publish 13
// channels→ACTIVE (artmura-proven GraphQL). DRY-RUN by default; --apply --limit=N to write.
// Private-label scrub: titles/tags carry NO Momentum/Versa/Innovations; image is the cloudinary
// transient src Shopify rehosts (never stored customer-facing). Audit JSONL, idempotent.
import fs from 'node:fs';

const env = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8');
const STORE = (env.match(/^SHOPIFY_STORE=(.+)$/m) || [])[1].trim();
const TOKEN = (env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1].trim();
const REST = `https://${STORE}/admin/api/2024-10`;
const GQL = `${REST}/graphql.json`;
const H = { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' };
const APPLY = process.argv.includes('--apply');
const LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || '0', 10);
const AUDIT = new URL('hollywood-create-audit.jsonl', import.meta.url).pathname;
const MANIFEST = (process.argv.find(a => a.startsWith('--manifest=')) || '').split('=')[1] || '';

const LOCATION_ID = 'gid://shopify/Location/5795643504';     // 15442 Ventura Blvd
const TARGET_QTY = 2026;
const PUBLICATIONS = [22208643184,22497296496,29646651457,29739483201,29776969793,37904089153,
  43657658419,44234276915,44317474867,44317507635,71898464307,115856375859,140027723827].map(n => `gid://shopify/Publication/${n}`);

const sleep = ms => new Promise(r => setTimeout(r, ms));
// scrub: never expose the upstream maker in customer-facing text
const scrub = s => String(s || '').replace(/\b(momentum(\s+textiles( and walls)?)?|versa(’s)?(\s+designed\s+surfaces)?|innovations(\s+usa)?)\b/gi, '').replace(/\s{2,}/g, ' ').replace(/\s+\|/g, ' |').trim();
const unitWord = u => ({ SR: 'Roll', EA: 'Panel', BOX: 'Box', PANEL: 'Panel', SY: 'Sq Yard', YD: 'Yard' }[u] || 'Yard');

function payload(it) {
  const title = scrub(`${it.pattern}${it.color ? ' ' + it.color : ''} | Hollywood Wallcoverings`);
  const tags = scrub(it.tags).split(',').map(t => t.trim()).filter(Boolean);
  if (!tags.some(t => /hollywood/i.test(t))) tags.unshift('Hollywood Wallcoverings');
  const dw = it.dw_sku;
  const unit = unitWord(it.uom);
  const row = (label, val) => { const v = scrub(val); return v ? `<tr><td>${label}</td><td>${v}</td></tr>` : ''; };
  const body = (it.description ? `<p>${scrub(it.description)}</p>` : '') +
    `<table><tr><td>Width</td><td>${it.width || ''}"</td></tr>` +
    row('Weight', it.weight) +
    row('Content', it.content) +
    row('Finish', it.finish) +
    row('Repeat', it.repeat) +
    row('Fire Rating', it.fire_rating) +
    row('Cleaning', it.cleaning) +
    row('Panel Spec', it.spec) +
    `<tr><td>Sold Per</td><td>${unit}</td></tr></table>`;
  // spec metafields — same schema as the healthy line (global.* + custom/dwc identity keys);
  // 376 products shipped metafield-less before 2026-07-15 because this step didn't exist.
  const sl = 'single_line_text_field', ml = 'multi_line_text_field';
  const metafields = [];
  // single_line_text_field metafields reject newlines (Shopify 422 "must be a single line text
  // string") — collapse whitespace for sl values; ml (multi_line) keeps its newlines. TK-00109
  // canary caught this on fire_rating cert text (8/20 creates failed before this).
  const mf = (namespace, key, value, type = sl) => {
    let v = scrub(value);
    if (type === sl) v = v.replace(/\s+/g, ' ').trim();
    if (v) metafields.push({ namespace, key, type, value: v });
  };
  mf('global', 'width', it.width ? `${it.width}"` : '');
  if (/lb\b/i.test(String(it.weight || ''))) mf('global', 'v_prods_weight', (String(it.weight).match(/[\d.]+/) || [])[0]);
  mf('global', 'unit_of_measure', `Sold Per ${unit}`);
  mf('global', 'repeat', it.repeat);
  mf('global', 'fire_rating', it.fire_rating);
  mf('global', 'lead_time', 'Usually in Stock');
  mf('custom', 'pattern_name', it.pattern);
  mf('custom', 'supplier_name', 'Hollywood Wallcoverings');
  mf('custom', 'manufacturer_sku', it.mfr_sku);
  mf('custom', 'material', it.content, ml);
  // real_vendor = the TRUE upstream vendor (internal purchasing field, never theme-rendered).
  // Hollywood Wallcoverings is the BRAND, not the vendor — Steve 2026-07-15. Pushed directly
  // because scrub() would erase "Momentum".
  metafields.push({ namespace: 'dwc', key: 'real_vendor', type: sl, value: 'Momentum' });
  mf('dwc', 'manufacturer_sku', it.mfr_sku);
  return { product: {
    title, vendor: 'Hollywood Wallcoverings', product_type: it.product_type || 'Wallcovering', status: 'draft',
    tags: tags.join(', '), body_html: body, metafields, images: it.image ? [{ src: it.image }] : [],
    options: [{ name: 'Size' }],
    variants: [
      { sku: dw, price: String(parseFloat(it.hw).toFixed(2)), option1: `Sold Per ${unit}`, taxable: true, requires_shipping: true },
      { sku: `${dw}-Sample`, price: '4.25', option1: 'Sample', taxable: true, requires_shipping: true },
    ],
  } };
}

async function gql(query, variables) {
  for (let a = 0; a < 4; a++) {
    const r = await fetch(GQL, { method: 'POST', headers: H, body: JSON.stringify({ query, variables }) });
    const j = await r.json();
    if (j.errors) { if (a === 3) throw new Error(JSON.stringify(j.errors)); await sleep(900 * (a + 1)); continue; }
    return j.data;
  }
}
const Q_V = `query($id:ID!){product(id:$id){status variants(first:10){edges{node{id sku inventoryItem{id}}}}}}`;
const M_TRACK = `mutation($id:ID!){inventoryItemUpdate(id:$id,input:{tracked:true}){userErrors{message}}}`;
const M_ACT = `mutation($iid:ID!,$loc:ID!){inventoryActivate(inventoryItemId:$iid,locationId:$loc){userErrors{message}}}`;
const M_QTY = `mutation($input:InventorySetQuantitiesInput!){inventorySetQuantities(input:$input){userErrors{message}}}`;
const M_PUB = `mutation($id:ID!,$pubs:[PublicationInput!]!){publishablePublish(id:$id,input:$pubs){userErrors{message}}}`;
const M_ACTIVE = `mutation($id:ID!){productUpdate(input:{id:$id,status:ACTIVE}){product{status}userErrors{message}}}`;

async function goLive(pid) {
  const gid = `gid://shopify/Product/${pid}`;
  const d = await gql(Q_V, { id: gid });
  const items = d.product.variants.edges.map(e => e.node.inventoryItem.id);
  for (const iid of items) { await gql(M_TRACK, { id: iid }); await gql(M_ACT, { iid, loc: LOCATION_ID }); }
  await gql(M_QTY, { input: { name: 'on_hand', reason: 'correction', ignoreCompareQuantity: true, quantities: items.map(iid => ({ inventoryItemId: iid, locationId: LOCATION_ID, quantity: TARGET_QTY })) } });
  await gql(M_PUB, { id: gid, pubs: PUBLICATIONS.map(p => ({ publicationId: p })) });
  const r = await gql(M_ACTIVE, { id: gid });
  return { status: r.productUpdate?.product?.status, varCount: items.length };
}

const items = JSON.parse(fs.readFileSync(MANIFEST || new URL('the67_full.json', import.meta.url).pathname, 'utf8'));
// resume-skip: don't recreate items already CREATED in the audit (idempotent drip)
const createdSet = new Set();
if (fs.existsSync(AUDIT)) for (const l of fs.readFileSync(AUDIT, 'utf8').trim().split('\n').filter(Boolean)) {
  try { const r = JSON.parse(l); if (r.action === 'CREATED') createdSet.add(r.dw); } catch {}
}
const remaining = items.filter(it => !createdSet.has(it.dw_sku));
const todo = LIMIT > 0 ? remaining.slice(0, LIMIT) : remaining;
if (createdSet.size) console.log(`resume: ${createdSet.size} already created, ${remaining.length} remain`);
const out = APPLY ? fs.createWriteStream(AUDIT, { flags: 'a' }) : null;
console.log(`hollywood-create: ${todo.length} items · ${APPLY ? 'APPLY' : 'DRY-RUN'}`);
let created = 0, err = 0;
for (const it of todo) {
  const pl = payload(it);
  // Leak-guard scans customer-facing text + metafields, but EXCLUDES the one field that
  // intentionally carries the upstream maker for internal purchasing (dwc.real_vendor='Momentum',
  // never theme-rendered — added 2026-07-15). Without this exclusion every item false-flags LEAK
  // and --apply skips 100% of creates (TK-00109 canary caught this). Genuine leaks elsewhere still trip.
  const leakText = pl.product.title + ' ' + pl.product.tags + ' ' + pl.product.body_html + ' ' +
    pl.product.metafields.filter(m => !(m.namespace === 'dwc' && m.key === 'real_vendor')).map(m => m.value).join(' ');
  const leak = /\b(momentum|versa|innovations|muratto)\b/i.test(leakText);
  if (!APPLY) {
    console.log(`\n${it.dw_sku}  "${pl.product.title}"  ${leak ? '⚠LEAK' : 'clean'}`);
    console.log(`  variants: [${pl.product.variants.map(v => v.option1 + ' ' + v.sku + ' $' + v.price).join('  |  ')}]`);
    console.log(`  tags: ${pl.product.tags.slice(0, 90)} · img: ${pl.product.images[0]?.src ? 'yes' : 'NONE'}`);
    continue;
  }
  if (leak) { console.error(`  SKIP LEAK ${it.dw_sku}`); err++; continue; }
  try {
    const r = await fetch(`${REST}/products.json`, { method: 'POST', headers: H, body: JSON.stringify(pl) });
    const txt = await r.text();
    // CAP-ABORT: Shopify daily variant-creation limit → stop cleanly, resume next drip (idempotent via audit).
    if (r.status === 429 && /daily variant creation limit/i.test(txt)) {
      console.error(`  ⛔ daily variant-creation cap reached at ${created} created — stopping (resume next run).`);
      out.write(JSON.stringify({ dw: it.dw_sku, action: 'CAP_ABORT', created }) + '\n'); out.end();
      console.log(`\nCAP-ABORT: created=${created} this run; ${todo.length - created - err} remain. Exit 3.`);
      process.exit(3);
    }
    let j; try { j = JSON.parse(txt); } catch { j = {}; }
    if (!r.ok || !j.product) { console.error(`  ERR create ${it.dw_sku}: ${r.status}`); out.write(JSON.stringify({ dw: it.dw_sku, action: 'ERR_CREATE', status: r.status, body: txt.slice(0,120) }) + '\n'); err++; await sleep(700); continue; }
    const pid = j.product.id;
    const variants = j.product.variants.map(v => ({ sku: v.sku, price: v.price, opt: v.option1 }));
    const sampleOk = variants.some(v => /sample/i.test(v.sku) && v.price === '4.25');
    const gl = await goLive(pid);
    out.write(JSON.stringify({ dw: it.dw_sku, pid, action: 'CREATED', status: gl.status, variants, sampleOk, varCount: gl.varCount }) + '\n');
    console.log(`  ✓ ${it.dw_sku} → ${pid} ${gl.status} variants=${variants.length} sampleOk=${sampleOk}`);
    created++; await sleep(500);
  } catch (e) { console.error(`  ERR ${it.dw_sku}: ${e.message.slice(0, 120)}`); err++; await sleep(700); }
}
if (out) out.end();
console.log(`\nDONE: created=${created} err=${err} of ${todo.length}` + (APPLY ? ` · audit ${AUDIT}` : ' · DRY-RUN, no writes'));