← back to Rebel Walls Push

scripts/gen-rollback-ledger.js

121 lines

#!/usr/bin/env node
/**
 * TK-10029: Generate a fresh, COMPLETE pre-apply rollback ledger from current live
 * Shopify state, mirroring relabel-units.js's exact rename decision (canonical
 * "Mural (per m²)"). Captures old_value -> new_value + undo for EVERY product that
 * --apply will rename, so 100% of the apply is reversible. Read-only (no writes).
 * Output: data/relabel-rollback-map-<ISO>.json  (same schema as prior durable ledger)
 */
const https = require('https');
const fs = require('fs');
const path = require('path');

const SECRETS_ENV = '/Users/macstudio3/Projects/secrets-manager/.env';
const DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
const API_VERSION = '2024-10';
const CORRECT_LABEL = 'Mural (per m²)';

function getToken() {
  const env = fs.readFileSync(SECRETS_ENV, 'utf8');
  const m = env.match(/^SHOPIFY_FULL_ACCESS_TOKEN=(.+)$/m);
  if (!m) throw new Error('SHOPIFY_FULL_ACCESS_TOKEN not found');
  return m[1].trim();
}
const TOKEN = getToken();

const WRONG_MURAL_LABELS = new Set([
  'roll', 'single roll', 'sold per roll', 'complete mural',
  'sold per bolt (20.5in x 33ft)', 'default title',
  'sold per square meter', 'sold per square metre',
]);
function isWrongMuralLabel(value) {
  const lower = value.toLowerCase();
  if (WRONG_MURAL_LABELS.has(lower)) return true;
  if (lower.startsWith('mural (per m') && value !== CORRECT_LABEL) return true;
  return false;
}

function gql(query, vars) {
  return new Promise((resolve, reject) => {
    const body = JSON.stringify({ query, variables: vars || {} });
    const req = https.request({
      hostname: DOMAIN, path: `/admin/api/${API_VERSION}/graphql.json`, method: 'POST',
      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
    }, res => { let d=''; res.on('data',c=>d+=c); res.on('end',()=>{try{resolve(JSON.parse(d))}catch(e){reject(e)}}); });
    req.on('error', reject); req.write(body); req.end();
  });
}
const sleep = ms => new Promise(r => setTimeout(r, ms));

function isSampleProduct(product) {
  const t = product.title.toLowerCase();
  return t.includes('memo sample') || t.includes(' sample') || t.endsWith('sample');
}

async function fetchAll() {
  const products = []; let cursor = null;
  while (true) {
    const q = `query($after: String) {
      products(first: 250, query: "vendor:Rebel Walls", after: $after) {
        pageInfo { hasNextPage endCursor }
        edges { node { id title options { id name values }
          variants(first: 10) { edges { node { id sku title selectedOptions { name value } } } } } }
      }
    }`;
    const r = await gql(q, { after: cursor });
    const page = r.data.products;
    for (const e of page.edges) products.push(e.node);
    if (!page.pageInfo.hasNextPage) break;
    cursor = page.pageInfo.endCursor; await sleep(300);
  }
  return products;
}

(async () => {
  const all = await fetchAll();
  const renames = [];
  const review_orphans = [];
  for (const product of all) {
    if (isSampleProduct(product)) continue;
    // does any variant carry a wrong mural label?
    const hasWrong = product.variants.edges.map(e=>e.node)
      .some(v => v.selectedOptions.some(o => isWrongMuralLabel(o.value)));
    if (!hasWrong) continue;

    const optionToFix = product.options.find(o => o.values.some(v => isWrongMuralLabel(v)))
      || product.options.find(o => o.name === 'Size' || o.name === 'Title');
    if (!optionToFix) continue;
    const wrongValues = optionToFix.values.filter(v => isWrongMuralLabel(v));
    if (!wrongValues.length) continue;
    const canonicalAlreadyPresent = optionToFix.values.includes(CORRECT_LABEL);
    const nonDefault = wrongValues.filter(v => v.toLowerCase() !== 'default title');
    const primary = nonDefault[0] || wrongValues[0];
    const toRename = canonicalAlreadyPresent ? [] : [primary];
    const toReview = canonicalAlreadyPresent ? wrongValues : wrongValues.filter(v => v !== primary);
    if (!toRename.length) {
      review_orphans.push({ productId: product.id, title: product.title,
        optionId: optionToFix.id, optionName: optionToFix.name, redundant_values: toReview });
      continue;
    }
    renames.push({
      productId: product.id, title: product.title,
      optionId: optionToFix.id, optionName: optionToFix.name,
      old_value: primary, new_value: CORRECT_LABEL,
      review_leftover: toReview,
      undo: `rename option value "${CORRECT_LABEL}" back to "${primary}" on option ${optionToFix.id}`,
    });
  }
  const out = {
    ticket: 'TK-10029',
    generated: new Date().toISOString(),
    canonical: CORRECT_LABEL,
    rename_count: renames.length,
    review_count: review_orphans.length,
    renames, review_orphans,
  };
  const fn = path.join(__dirname, '..', 'data', `relabel-rollback-map-${out.generated.replace(/[:.]/g,'-')}.json`);
  fs.writeFileSync(fn, JSON.stringify(out, null, 2));
  console.log(`[gen-rollback] wrote ${fn}`);
  console.log(`[gen-rollback] renames=${renames.length} review_orphans=${review_orphans.length}`);
})().catch(e => { console.error('[gen-rollback] FATAL', e.message); process.exit(1); });