← back to Muralsource Curator

mural-source-create.mjs

147 lines

// Create Mural Source products on the LIVE DW Shopify store — modeled on the proven
// hollywood-create.mjs (sample-SAFE by construction: BOTH variants in ONE POST /products.json,
// so productVariantsBulkCreate's sample-overwrite bug cannot fire). Then go-live:
// track→activate→qty 2026→publish 13 channels→ACTIVE. Image = live BigCommerce URL which
// Shopify fetches + rehosts to its own CDN (the "localize" step). vendor:"Mural Source" exactly
// matches the smart-collection rule → auto-populates the mural-source collection.
// Mural Source is a CUSTOMER-FACING brand → NO scrub. DRY-RUN default; --apply --limit=N to write.
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 MANIFEST = (process.argv.find(a => a.startsWith('--manifest=')) || '').split('=')[1]
  || new URL('wave1_six.json', import.meta.url).pathname;
const AUDIT = new URL('mural-source-create-audit.jsonl', import.meta.url).pathname;

const LOCATION_ID = 'gid://shopify/Location/5795643504';   // 15442 Ventura Blvd (same store as hollywood)
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));
const esc = s => String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const tagArr = s => { try { return JSON.parse(s); } catch { return []; } };

function payload(it) {
  const pattern = it.pattern_name, color = it.color_name || '';
  const title = `${pattern}${color ? ' ' + color : ''} | Mural Source`;
  const unit = 'Priced Per Single Roll';
  // tags: ai_tags + brand/type identity + color; dedup; guarantee >=2
  let tags = tagArr(it.ai_tags).map(t => String(t).trim()).filter(Boolean);
  for (const must of ['Mural Source', 'Wallcovering', 'Mural']) if (!tags.some(t => t.toLowerCase() === must.toLowerCase())) tags.push(must);
  if (color && !tags.some(t => t.toLowerCase() === color.toLowerCase())) tags.push(color);
  tags = [...new Set(tags)];
  // description: ai_description if present, else deterministic fallback (satisfies 5-field desc gate)
  const desc = it.ai_description && it.ai_description.length > 20
    ? it.ai_description
    : `${pattern}${color ? ' in ' + color : ''} — a ${it.product_type || 'wallcovering'} from Mural Source${it.material ? ', ' + it.material.toLowerCase() : ''}. Sold ${unit.toLowerCase()}.`;
  const row = (l, v) => v ? `<tr><td>${l}</td><td>${esc(v)}</td></tr>` : '';
  const body = `<p>${esc(desc)}</p><table>` +
    row('Width', it.width) + row('Length', it.length) + row('Repeat', it.repeat_v) +
    row('Material', it.material) + `<tr><td>Sold Per</td><td>${unit}</td></tr></table>`;
  const sl = 'single_line_text_field', ml = 'multi_line_text_field';
  const metafields = [];
  const mf = (ns, key, val, type = sl) => { if (val) metafields.push({ namespace: ns, key, type, value: String(val) }); };
  mf('global', 'Brand', 'Mural Source');
  mf('global', 'width', it.width);
  mf('global', 'length', it.length);
  mf('global', 'repeat', it.repeat_v);
  mf('global', 'unit_of_measure', unit);
  mf('global', 'lead_time', 'Usually in Stock');
  mf('custom', 'pattern_name', pattern);
  mf('custom', 'supplier_name', 'Mural Source');
  mf('custom', 'manufacturer_sku', it.mfr_sku);
  mf('custom', 'color', color);
  mf('custom', 'real_color_name', color);
  mf('custom', 'material', it.material, ml);
  mf('dwc', 'manufacturer_sku', it.mfr_sku);
  return { product: {
    title, vendor: 'Mural Source', product_type: it.product_type || 'Wallcovering', status: 'draft',
    tags: tags.join(', '), body_html: body, metafields, images: it.image_url ? [{ src: it.image_url }] : [],
    options: [{ name: 'Size' }],
    variants: [
      { sku: it.dw_sku, price: String(parseFloat(it.price_retail).toFixed(2)), option1: unit, taxable: true, requires_shipping: true },
      { sku: `${it.dw_sku}-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, 'utf8'));
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(`mural-source-create: ${todo.length} items · ${APPLY ? 'APPLY (LIVE)' : 'DRY-RUN'}`);
let created = 0, err = 0;
for (const it of todo) {
  const pl = payload(it);
  const fiveField = pl.product.images.length > 0 && pl.product.body_html.length > 30
    && pl.product.tags.split(',').filter(Boolean).length >= 2
    && pl.product.variants.some(v => /-Sample$/.test(v.sku) && v.price === '4.25')
    && pl.product.variants.some(v => !/-Sample$/.test(v.sku) && parseFloat(v.price) > 0);
  if (!APPLY) {
    console.log(`\n${it.dw_sku}  "${pl.product.title}"  5field=${fiveField ? 'OK' : '⚠FAIL'}`);
    console.log(`  variants: [${pl.product.variants.map(v => v.option1 + ' ' + v.sku + ' $' + v.price).join('  |  ')}]`);
    console.log(`  tags(${pl.product.tags.split(',').length}): ${pl.product.tags.slice(0, 100)}`);
    console.log(`  img: ${pl.product.images[0]?.src ? 'yes' : 'NONE'} · metafields: ${pl.product.metafields.length}`);
    continue;
  }
  if (!fiveField) { console.error(`  SKIP 5-field FAIL ${it.dw_sku}`); out.write(JSON.stringify({ dw: it.dw_sku, action: 'SKIP_5FIELD' }) + '\n'); err++; continue; }
  try {
    const r = await fetch(`${REST}/products.json`, { method: 'POST', headers: H, body: JSON.stringify(pl) });
    const txt = await r.text();
    if (r.status === 429 && /daily variant creation limit/i.test(txt)) {
      console.error(`  ⛔ daily variant-creation cap at ${created} — stopping (resume next run).`);
      out.write(JSON.stringify({ dw: it.dw_sku, action: 'CAP_ABORT', created }) + '\n'); out.end(); 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} ${txt.slice(0,120)}`); out.write(JSON.stringify({ dw: it.dw_sku, action: 'ERR_CREATE', status: r.status, body: txt.slice(0,160) }) + '\n'); err++; await sleep(700); continue; }
    const pid = j.product.id;
    const handle = j.product.handle;
    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, mfr: it.mfr_sku, pid, handle, 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, 140)}`); 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'));