← back to Rebel Walls Push

scripts/build-rollback-map-B.js

133 lines

#!/usr/bin/env node
/**
 * TK-10029 Scope B rollback-map generator.
 * Mirrors the EXACT --hold-rollbolt FIX selection in relabel-units.js so it captures
 * old->new for precisely the rows the `--apply --hold-rollbolt` run will touch (the 1220
 * per-m² cohort). Read-only. Writes data/relabel-rollback-map-B-<ts>.json.
 *
 * Reverse plan (from any captured row): for product_id, find the option value on option_id
 * whose current name === new_value ("Mural (per m²)") and rename it back to old_value via
 * the same productOptionUpdate mutation. FIX products did NOT carry the canonical before the
 * apply (co-present canonical => REVIEW, not FIX), so post-apply the canonical maps uniquely
 * back to old_value — deterministic, non-destructive reverse.
 */
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²)';

// held roll/bolt cohort — Scope B holds these (never a FIX row)
const HELD_ROLLBOLT_LABELS = new Set([
  'roll', 'single roll', 'sold per roll', 'sold per bolt (20.5in x 33ft)',
]);
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 (HELD_ROLLBOLT_LABELS.has(lower)) return false; // Scope B hold
  if (WRONG_MURAL_LABELS.has(lower)) return true;
  if (lower.startsWith('mural (per m') && value !== CORRECT_LABEL) return true;
  return false;
}
function isSampleProduct(product) {
  const t = product.title.toLowerCase();
  return t.includes('memo sample') || t.includes(' sample') || t.endsWith('sample');
}

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();

async 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));

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 { sku 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;
}

// replicate relabelProduct's FIX-row decision (Scope B): returns the single row that WILL be
// renamed, or null if this product is REVIEW/SKIP/no-fix.
function fixRowFor(product) {
  if (isSampleProduct(product)) return null;
  const anyWrong = product.variants.edges.some(e => e.node.selectedOptions.some(o => isWrongMuralLabel(o.value)));
  if (!anyWrong) return null;
  const optionToFix =
    product.options.find(o => o.values.some(v => isWrongMuralLabel(v))) ||
    product.options.find(o => o.name === 'Size' || o.name === 'Title');
  if (!optionToFix) return null;
  const wrongValues = optionToFix.values.filter(v => isWrongMuralLabel(v));
  if (!wrongValues.length) return null;
  const canonicalAlreadyPresent = optionToFix.values.includes(CORRECT_LABEL);
  if (canonicalAlreadyPresent) return null; // REVIEW, not a FIX row
  const nonDefault = wrongValues.filter(v => v.toLowerCase() !== 'default title');
  const primary = nonDefault[0] || wrongValues[0];
  return {
    product_id: product.id,
    title: product.title,
    option_id: optionToFix.id,
    option_name: optionToFix.name,
    old_value: primary,
    new_value: CORRECT_LABEL,
  };
}

(async () => {
  const all = await fetchAll();
  const rows = [];
  for (const p of all) { const r = fixRowFor(p); if (r) rows.push(r); }
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
  const out = {
    ticket: 'TK-10029',
    scope: 'B',
    generated_at: new Date().toISOString(),
    store: DOMAIN,
    api_version: API_VERSION,
    correct_label: CORRECT_LABEL,
    held_rollbolt_labels: [...HELD_ROLLBOLT_LABELS],
    count: rows.length,
    reverse_plan: 'For each row: on product_id, find the option value on option_id whose name === new_value and rename it back to old_value via productOptionUpdate. FIX products did not carry the canonical pre-apply, so the reverse is deterministic + non-destructive.',
    rows,
  };
  const file = path.join(__dirname, '..', 'data', `relabel-rollback-map-B-${ts}.json`);
  fs.writeFileSync(file, JSON.stringify(out, null, 2));
  const byOld = {};
  for (const r of rows) byOld[r.old_value] = (byOld[r.old_value] || 0) + 1;
  console.log(`[rollback-map-B] products=${all.length} FIX-rows=${rows.length}`);
  console.log('[rollback-map-B] old_value breakdown:', JSON.stringify(byOld));
  console.log('[rollback-map-B] wrote', file);
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });