← back to Dw Unbuyable Recovery Pilot

tk11041-innovations-reconcile/rescrape/golive-shopify.mjs

170 lines

#!/usr/bin/env node
/**
 * TK-11041 — Innovations (private-label Phillipe Romano) go-live on Shopify.
 * Make the 39 live-but-unbuyable sample-only products BUYABLE by adding a priced
 * sellable variant (per-yard, or per-ROLL for Skylark), preserving the $4.25 sample,
 * then ensure each is published to Online Store + Google & YouTube.
 *
 * Mechanism is the LIVE-PROVEN one from scripts/justin-david-pricing/build-jd-yard-variants.mjs
 * (2026-06-17): productVariantsBulkCreate + strategy:REMOVE_STANDALONE_VARIANT, RE-ASSERTING the
 * sample AND the sellable variant in the SAME call so the $4.25 sample is preserved, plus a
 * self-heal verify (re-query, confirm sample AND sellable survived) before marking done.
 *
 * DEFAULT = DRY-RUN. Requires --execute AND --i-am-steve to write. (Steve APPROVED go-live 2026-09-15.)
 *
 *   node golive-shopify.mjs                              # dry-run all 39
 *   node golive-shopify.mjs --execute --i-am-steve --limit=1   # LIVE canary (1 product)
 *   node golive-shopify.mjs --execute --i-am-steve             # LIVE all 39
 */
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, v] = a.replace(/^--/, '').split('='); return [k, v === undefined ? true : v]; }));
const EXECUTE = args.execute === true && args['i-am-steve'] === true;
const LIMIT = args.limit ? parseInt(args.limit, 10) : Infinity;
const ONLY_PID = args.pid ? String(args.pid) : null;

const TABLE = JSON.parse(fs.readFileSync(path.join(HERE, 'data/tk11041-authoritative-table.json'), 'utf8'));
const TOKEN = (fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1].trim();
const SHOP_URL = 'https://designer-laboratory-sandbox.myshopify.com/admin/api/2024-10/graphql.json';
const PUB_ONLINE = 'gid://shopify/Publication/22208643184';   // Online Store
const PUB_GOOGLE = 'gid://shopify/Publication/29646651457';   // Google & YouTube
const UNDO = path.join(HERE, 'undo');
const ROLLBACK = path.join(UNDO, 'tk11041-golive-shopify-rollback.jsonl');
const SNAPDIR = path.join(UNDO, 'shopify-prestate');
fs.mkdirSync(SNAPDIR, { recursive: true });
const sleep = ms => new Promise(r => setTimeout(r, ms));

async function gql(q, v) {
  for (let i = 0; i < 8; i++) {
    let r; const ac = new AbortController(); const to = setTimeout(() => ac.abort(), 25000);
    try { r = await fetch(SHOP_URL, { method: 'POST', signal: ac.signal, headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, variables: v }) }); }
    catch (e) { clearTimeout(to); await sleep(1500 * (i + 1)); continue; }
    clearTimeout(to);
    if (r.status === 429 || r.status >= 500) { await sleep(2000 * (i + 1)); continue; }
    let j; try { j = await r.json(); } catch (e) { await sleep(2000 * (i + 1)); continue; }
    if (j.errors && JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (i + 1)); continue; }
    if (j.errors) return { __err: j.errors };
    const t = j.extensions?.cost?.throttleStatus; if (t && t.currentlyAvailable < 400) await sleep(1200);
    return j.data;
  }
  throw new Error('exhausted retries');
}

const LOOKUP = `query($id:ID!){ node(id:$id){ ... on Product {
  id title status handle vendor
  featuredImage{url}
  widthMeta: metafield(namespace:"custom", key:"width"){value}
  options{ id name values }
  variants(first:25){ nodes{ id sku title price inventoryPolicy inventoryItem{ id tracked measurement{weight{value unit}} } selectedOptions{name value} } }
  resourcePublicationsV2(first:30){ nodes{ publication{ id name } isPublished } }
} } }`;

const CREATE = `mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){
  productVariantsBulkCreate(productId:$pid,variants:$variants,strategy:REMOVE_STANDALONE_VARIANT){
    productVariants{ id sku title price inventoryPolicy } userErrors{ field message } } }`;

const PUBLISH = `mutation($id:ID!,$input:[PublicationInput!]!){
  publishablePublish(id:$id, input:$input){ userErrors{ field message } } }`;

const DELETE = `mutation($pid:ID!,$ids:[ID!]!){
  productVariantsBulkDelete(productId:$pid,variantsIds:$ids){ userErrors{ field message } } }`;

function buildVariants({ optName, sampleSku, sellSku, sellValue, sellPrice, sellWeight }) {
  return [
    { price: '4.25',            optionValues: [{ optionName: optName, name: 'Sample' }],   inventoryPolicy: 'CONTINUE', inventoryItem: { sku: sampleSku, tracked: false, measurement: { weight: { value: 0.25, unit: 'POUNDS' } } } },
    { price: String(sellPrice), optionValues: [{ optionName: optName, name: sellValue }],  inventoryPolicy: 'CONTINUE', inventoryItem: { sku: sellSku,   tracked: false, measurement: { weight: { value: sellWeight, unit: 'POUNDS' } } } },
  ];
}

(async () => {
  const rows = TABLE.rows
    .map(r => ({ ...r, pidNum: String(r.shopify_product_id).replace('gid://shopify/Product/', '') }))
    .filter(r => !ONLY_PID || r.pidNum === ONLY_PID)
    .slice(0, LIMIT === Infinity ? undefined : LIMIT);

  console.log(`TK-11041 golive-shopify — ${EXECUTE ? '⚠️  LIVE writes' : 'DRY-RUN (no writes)'} — ${rows.length} product(s)`);
  const rb = EXECUTE ? fs.createWriteStream(ROLLBACK, { flags: 'a' }) : null;
  let ok = 0, skipped = 0, errored = 0, healFail = 0;

  for (const r of rows) {
    const gid = `gid://shopify/Product/${r.pidNum}`;
    const lk = await gql(LOOKUP, { id: gid });
    const p = lk && !lk.__err && lk.node;
    if (!p) { skipped++; console.log(`SKIP ${r.mfr_sku}: product-not-found ${JSON.stringify(lk?.__err||'').slice(0,120)}`); continue; }

    // snapshot BEFORE any write (memo requirement)
    fs.writeFileSync(path.join(SNAPDIR, `${r.mfr_sku}.json`), JSON.stringify({ mfr_sku: r.mfr_sku, snapshot_at: new Date().toISOString(), product: p }, null, 2));

    const vs = p.variants.nodes;
    // gate: DW rule — never make buyable/ACTIVE without image + width metafield
    const hasImg = !!p.featuredImage?.url;
    const hasWidth = !!(p.widthMeta && p.widthMeta.value);
    if (!hasImg || !hasWidth) { skipped++; console.log(`SKIP ${r.mfr_sku}: missing ${!hasImg?'image ':''}${!hasWidth?'width-metafield':''} — held (Needs-Image/Needs-Width)`); continue; }

    // idempotency: already has a non-sample sellable variant?
    if (vs.some(v => !(v.sku||'').endsWith('-Sample') && parseFloat(v.price) > 4.30)) {
      skipped++; console.log(`SKIP ${r.mfr_sku}: already buyable (non-sample variant present)`); continue;
    }
    // must be sample-only (1 variant, sample-priced, -Sample sku)
    if (!(vs.length === 1 && parseFloat(vs[0].price) <= 4.30)) {
      skipped++; console.log(`SKIP ${r.mfr_sku}: not sample-only (nvar=${vs.length} price0=${vs[0]?.price})`); continue;
    }
    const sampleSku = vs[0].sku || '';
    if (!/-Sample$/i.test(sampleSku)) { skipped++; console.log(`SKIP ${r.mfr_sku}: sample sku not -Sample (${sampleSku})`); continue; }
    const sellSku = sampleSku.replace(/-Sample$/i, '');
    // Reuse the product's EXISTING option name (the live-proven JD path). A NEW option name
    // ("Size") fails productVariantsBulkCreate with "Option does not exist".
    const optName = (p.options[0] && p.options[0].name) || 'Title';
    const isRoll = r.price_unit === 'roll';
    const sellValue = isRoll ? 'Sold Per Roll' : 'Sold Per Yard';
    const sellWeight = isRoll ? 20.0 : 2.0;
    const sellPrice = r.our_price;
    const variants = buildVariants({ optName, sampleSku, sellSku, sellValue, sellPrice, sellWeight });

    const alreadyPubOnline = p.resourcePublicationsV2.nodes.some(n => n.publication.id === PUB_ONLINE && n.isPublished);
    const alreadyPubGoogle = p.resourcePublicationsV2.nodes.some(n => n.publication.id === PUB_GOOGLE && n.isPublished);

    console.log(`${EXECUTE?'DO  ':'PLAN'} ${r.mfr_sku} ${p.status} "${p.title.slice(0,40)}" -> add ${sellSku} @ $${sellPrice} (${sellValue}, ${sellWeight}lb) + Sample ${sampleSku}; pub online=${alreadyPubOnline?'yes':'NEEDS'} google=${alreadyPubGoogle?'yes':'NEEDS'}`);

    if (!EXECUTE) { ok++; continue; }

    const cr = await gql(CREATE, { pid: gid, variants });
    if (cr?.__err) { errored++; console.log(`  ERR create ${r.mfr_sku}: ${JSON.stringify(cr.__err).slice(0,200)}`); continue; }
    const ue = cr.productVariantsBulkCreate?.userErrors || [];
    if (ue.length) { errored++; console.log(`  UERR ${r.mfr_sku}: ${JSON.stringify(ue)}`); continue; }
    rb.write(JSON.stringify({ mfr_sku: r.mfr_sku, pid: r.pidNum, old_sample_variant_id: vs[0].id, sell_sku: sellSku, created: (cr.productVariantsBulkCreate.productVariants||[]).map(v=>({id:v.id,sku:v.sku,price:v.price})) }) + '\n');

    // publish to Online Store + Google & YouTube (idempotent; publishing already-published is a no-op)
    const pubInput = [{ publicationId: PUB_ONLINE }, { publicationId: PUB_GOOGLE }];
    const pub = await gql(PUBLISH, { id: gid, input: pubInput });
    const pue = pub?.__err || pub?.publishablePublish?.userErrors;
    if (pub?.__err || (pub?.publishablePublish?.userErrors?.length)) console.log(`  PUB-WARN ${r.mfr_sku}: ${JSON.stringify(pue).slice(0,160)}`);

    // self-heal verify
    const re = await gql(LOOKUP, { id: gid });
    const rv = (re && !re.__err && re.node?.variants?.nodes) || [];
    const sampleOk = rv.some(v => (v.sku||'').endsWith('-Sample') && parseFloat(v.price) <= 4.30);
    const sellOk = rv.some(v => (v.sku||'') === sellSku && Math.abs(parseFloat(v.price) - sellPrice) < 0.005 && v.inventoryPolicy === 'CONTINUE');
    const rpub = (re?.node?.resourcePublicationsV2?.nodes)||[];
    const pubOnlineOk = rpub.some(n => n.publication.id === PUB_ONLINE && n.isPublished);
    const pubGoogleOk = rpub.some(n => n.publication.id === PUB_GOOGLE && n.isPublished);
    if (!sampleOk || !sellOk) {
      healFail++;
      console.log(`  ⛔ VERIFY FAIL ${r.mfr_sku}: sampleOk=${sampleOk} sellOk=${sellOk} — AUTO-ROLLBACK (delete sellable) + ABORT.`);
      const sellIds = rv.filter(v => (v.sku||'') === sellSku).map(v => v.id);
      if (sellIds.length) { const del = await gql(DELETE, { pid: gid, ids: sellIds }); console.log(`  ↩︎ rolled back: ${JSON.stringify(del?.productVariantsBulkDelete?.userErrors||'ok')}`); }
      break;
    }
    ok++;
    console.log(`  ✓ ${r.mfr_sku} buyable (sample✓ sell✓ pubOnline=${pubOnlineOk} pubGoogle=${pubGoogleOk})`);
    await sleep(200);
  }

  if (rb) rb.end();
  console.log(`\nDONE — ${ok} ${EXECUTE?'live':'planned'}, ${skipped} skipped, ${errored} errors, ${healFail} verify-fails`);
  if (healFail) process.exit(4);
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });