← back to Rebel Walls Push

scripts/TK-10405-fullfix.mjs

139 lines

#!/usr/bin/env node
// TK-10405 FULL FIX (supersedes A') for the 2 held Rebel Walls Default-Title orphans.
// Steve-approved in-session (TK-10405). Per product: REORDER Mural(per m²)->pos1,
// Sample->pos2, Default-Title orphan->pos3 (productVariantsBulkReorder, all positions),
// THEN DELETE the orphan (productVariantsBulkDelete). End state = sellable Mural at pos1,
// so PDP JSON-LD offers[0] reads $117.47. Reversible: restore map captures the orphan's
// full recreate payload + original positions; undo script recreates + reorders back.
//
// SCOPE = exactly these 2 products / delete exactly these 2 orphan variants:
//   6679729078323 dwrw-76016 A City Rises -> delete 44453186830387
//   6679739629619 dwrw-76264 A Priori     -> delete 44453188206643
//
// Run:  node scripts/TK-10405-fullfix.mjs
import fs from 'node:fs';
import { execFileSync } from 'node:child_process';

const DOM = 'designer-laboratory-sandbox.myshopify.com';
const VER = '2024-10';
const RESTORE = new URL('../data/TK-10405-restore-map.json', import.meta.url).pathname;
const LOGEXEC = process.env.HOME + '/.claude/yolo-queue/executed-reversible/log-exec.mjs';

const env = fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8');
const TOKEN = (env.match(/^SHOPIFY_FULL_ACCESS_TOKEN=(.+)$/m)?.[1] || '').trim().replace(/^["']|["']$/g, '');
if (!TOKEN) { console.error('no SHOPIFY_FULL_ACCESS_TOKEN'); process.exit(1); }

const TARGETS = [
  { pid: 'gid://shopify/Product/6679729078323', orphan: 'gid://shopify/ProductVariant/44453186830387', handle: 'dwrw-76016', label: 'A City Rises' },
  { pid: 'gid://shopify/Product/6679739629619', orphan: 'gid://shopify/ProductVariant/44453188206643', handle: 'dwrw-76264', label: 'A Priori' },
];

async function gql(query, variables, tries = 6) {
  for (let i = 0; i < tries; i++) {
    const r = await fetch(`https://${DOM}/admin/api/${VER}/graphql.json`, {
      method: 'POST',
      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
      body: JSON.stringify({ query, variables }),
    });
    if ([429, 500, 502, 503].includes(r.status) && i < tries - 1) { await new Promise(s => setTimeout(s, 1500 * (i + 1))); continue; }
    return r.json();
  }
}
const Q_PROD = `query($id:ID!){ product(id:$id){ id options{ name position values }
  variants(first:20){ nodes{ id title position price sku taxable inventoryPolicy inventoryQuantity
    selectedOptions{ name value } inventoryItem{ id tracked } } } } }`;
const M_REORDER = `mutation($productId:ID!,$positions:[ProductVariantPositionInput!]!){
  productVariantsBulkReorder(productId:$productId,positions:$positions){ product{ id } userErrors{ field message } } }`;
const M_DELETE = `mutation($productId:ID!,$variantsIds:[ID!]!){
  productVariantsBulkDelete(productId:$productId,variantsIds:$variantsIds){ product{ id } userErrors{ field message } } }`;

function ledger(rec) { execFileSync('node', [LOGEXEC], { input: JSON.stringify(rec), stdio: ['pipe', 'inherit', 'inherit'] }); }
const short = g => g.split('/').pop();

(async () => {
  const rm = JSON.parse(fs.readFileSync(RESTORE, 'utf8'));
  const ff = { ts: new Date().toISOString(), approved_by: 'Steve in-session TK-10405',
    action: 'FULL FIX: reorder Mural(per m²)->1, Sample->2, orphan->3, then DELETE orphan',
    undo: 'node scripts/TK-10405-fullfix-undo.mjs — recreates orphan (NEW id) via productVariantsBulkCreate from prestate + restores original positions',
    products: {} };

  // 1. CAPTURE pre-state (positions of all variants + full orphan recreate payload) BEFORE any write
  for (const t of TARGETS) {
    const p = (await gql(Q_PROD, { id: t.pid }))?.data?.product;
    if (!p) { console.error(`ABORT: product ${t.label} not found`); process.exit(1); }
    const vs = p.variants.nodes;
    const orphan = vs.find(v => v.id === t.orphan);
    const mural = vs.find(v => /^Mural/.test(v.title));
    const sample = vs.find(v => v.title === 'Sample');
    if (!orphan) { console.log(`SKIP ${t.label}: orphan ${short(t.orphan)} already absent (idempotent)`); t.orphanGone = true; }
    if (!mural || !sample) { console.error(`ABORT: ${t.label} missing Mural/Sample survivor`); process.exit(1); }
    t.mural = mural.id; t.sample = sample.id;
    ff.products[short(t.pid)] = {
      product_gid: t.pid, handle: t.handle, label: t.label,
      options: p.options,
      original_positions: vs.map(v => ({ id: v.id, title: v.title, position: v.position })),
      orphan_recreate: orphan ? { id: orphan.id, title: orphan.title, price: orphan.price, sku: orphan.sku,
        taxable: orphan.taxable, inventoryPolicy: orphan.inventoryPolicy, inventoryQuantity: orphan.inventoryQuantity,
        selectedOptions: orphan.selectedOptions, tracked: orphan.inventoryItem?.tracked } : null,
    };
  }
  rm.full_fix_reorder_delete = ff;
  fs.writeFileSync(RESTORE, JSON.stringify(rm, null, 2));
  console.log('PRE-STATE (positions + orphan recreate payload) recorded to restore map for', TARGETS.map(t => t.label).join(' + '));

  // 2. REORDER then DELETE per product
  for (const t of TARGETS) {
    console.log(`\n=== ${t.label} (${short(t.pid)}) ===`);
    const positions = [{ id: t.mural, position: 1 }, { id: t.sample, position: 2 }];
    if (!t.orphanGone) positions.push({ id: t.orphan, position: 3 });
    const rr = await gql(M_REORDER, { productId: t.pid, positions });
    const dr = rr?.data?.productVariantsBulkReorder;
    if (dr?.userErrors?.length || rr?.errors) { console.error('REORDER FAILED:', JSON.stringify(dr?.userErrors || rr.errors)); process.exit(1); }
    console.log('  reordered -> Mural pos1, Sample pos2' + (t.orphanGone ? '' : ', orphan pos3'));
    if (!t.orphanGone) {
      const rd = await gql(M_DELETE, { productId: t.pid, variantsIds: [t.orphan] });
      const dd = rd?.data?.productVariantsBulkDelete;
      if (dd?.userErrors?.length || rd?.errors) { console.error('DELETE FAILED:', JSON.stringify(dd?.userErrors || rd.errors)); process.exit(1); }
      console.log('  deleted orphan', short(t.orphan));
    }
  }

  // 3. VERIFY (Admin API) — orphan absent, Mural pos1 $117.47, Sample pos2
  console.log('\n===== VERIFY (Admin API) =====');
  let allok = true;
  for (const t of TARGETS) {
    const p = (await gql(Q_PROD, { id: t.pid })).data.product;
    const vs = p.variants.nodes.sort((a, b) => a.position - b.position);
    const orphanGone = !vs.find(v => v.id === t.orphan);
    const pos1 = vs[0];
    const muralPos1 = /^Mural/.test(pos1?.title || '') && pos1?.price === '117.47';
    const ok = orphanGone && muralPos1;
    allok = allok && ok;
    console.log(`${t.label}: orphan-absent=${orphanGone} pos1='${pos1?.title}' $${pos1?.price} (${muralPos1 ? 'OK' : 'BAD'})`);
    for (const v of vs) console.log(`    pos${v.position} ${short(v.id)} '${v.title}' $${v.price} sku=${v.sku}`);
  }

  // 4. VERIFY storefront (.json order + .js availability)
  console.log('\n===== VERIFY (rendered storefront) =====');
  for (const t of TARGETS) {
    try {
      const j = await (await fetch(`https://${DOM}/products/${t.handle}.json`, { headers: { 'User-Agent': 'Mozilla/5.0' } })).json();
      const v0 = j.product.variants[0];
      const hasOrphan = j.product.variants.some(x => x.title === 'Default Title');
      console.log(`${t.handle}: storefront pos1='${v0.title}' $${v0.price} | Default-Title present=${hasOrphan}` +
        (hasOrphan ? '  (CDN cache may lag a few min)' : ''));
    } catch (e) { console.error(`${t.handle} storefront fetch failed:`, e.message); }
  }

  // 5. LEDGER (one entry per product: reorder+delete)
  console.log('\n===== LEDGER =====');
  for (const t of TARGETS) {
    ledger({ agent: 'vp-dw-commerce', ticket: 'TK-10405',
      action: `FULL FIX on ${t.label} (${short(t.pid)}): reordered Mural(per m²)->pos1 + Sample->pos2, DELETED Default-Title orphan ${short(t.orphan)}`,
      blast_radius: 1, target: short(t.pid),
      undo_cmd: `node ~/Projects/rebel-walls-push/scripts/TK-10405-fullfix-undo.mjs  # recreates orphan (new id) + restores positions from restore-map.full_fix_reorder_delete`,
      verify: `product ${short(t.pid)}: orphan ${short(t.orphan)} absent; pos1=Mural(per m²) $117.47; Sample pos2; storefront offers[0]=$117.47` });
  }
  console.log('\n' + (allok ? 'FULL FIX COMPLETE — verification PASSED for both products.' : 'FULL FIX RAN but verification found a mismatch — review above.'));
})();