← back to Rebel Walls Push

scripts/relabel-units.js

260 lines

#!/usr/bin/env node
/**
 * TK-10029: Rebel Walls mural unit relabel.
 * Finds products with wrong variant labels ("Roll", "Single Roll", "Sold Per Bolt ...",
 * "Complete Mural", or "Default Title" on the mural variant) and relabels them to
 * "Sold Per Square Meter".
 *
 * Dry-run by default. Pass --apply to execute.
 * Logs every action. Safe to re-run (idempotent — skips already-correct products).
 *
 * Usage:
 *   node scripts/relabel-units.js             # dry-run, full audit
 *   node scripts/relabel-units.js --apply     # live Shopify writes
 *   node scripts/relabel-units.js --limit 10  # cap actions
 */
const https = require('https');
const fs = require('fs');

const SECRETS_ENV = '/Users/macstudio3/Projects/secrets-manager/.env';
const DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
const API_VERSION = '2024-10';

function getToken() {
  const env = fs.readFileSync(SECRETS_ENV, 'utf8');
  const m = env.match(/^SHOPIFY_FULL_ACCESS_TOKEN=(.+)$/m); // TK-10979 DTD-A: full-access routing (bridge; target=capability-scoped token)
  if (!m) throw new Error('SHOPIFY_FULL_ACCESS_TOKEN not found');
  return m[1].trim();
}
const TOKEN = getToken();

const args = process.argv.slice(2);
const DRY = !args.includes('--apply');
const LIMIT = (() => { const i = args.indexOf('--limit'); return i >= 0 ? parseInt(args[i+1]) : Infinity; })();
// TK-10029 Scope B (Steve GO 2026-09-03): --hold-rollbolt excises the roll/bolt unit-of-sale
// cohort from the wrong-label set entirely, so those variants are NEITHER FIXed NOR REVIEWed —
// they are held pending the separate roll/bolt unit-of-sale review (TK-10405). Purely subtractive:
// when absent, scope-A behavior is unchanged.
const HOLD_ROLLBOLT = args.includes('--hold-rollbolt');
const HELD_ROLLBOLT_LABELS = new Set([
  'roll', 'single roll', 'sold per roll', 'sold per bolt (20.5in x 33ft)',
]);

// TK-10029 / DTD 2026-08-10: canonical mural unit label = "Mural (per m²)" (Option A,
// unanimous 7/7). This is what the live importer push.js already mints, so we converge
// every OTHER mural-unit label onto it — including the legacy roll/bolt labels AND the
// interim "Sold Per Square Meter" cohort AND any mojibake-corrupted "Mural (per m<?>)".
const CORRECT_LABEL = 'Mural (per m²)';

// Values that indicate "this is the mural variant but has the wrong unit label"
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',
]);

// Robust matcher: catches the fixed set above AND any corrupted "Mural (per m…)" variant
// (e.g. the mojibake "Mural (per m�)") that is NOT already the exact canonical.
function isWrongMuralLabel(value) {
  const lower = value.toLowerCase();
  // Scope B: hold the roll/bolt unit-of-sale cohort — never FIX, never REVIEW-into-rename.
  if (HOLD_ROLLBOLT && HELD_ROLLBOLT_LABELS.has(lower)) return false;
  if (WRONG_MURAL_LABELS.has(lower)) return true;
  // mojibake / near-miss of the canonical: starts like "mural (per m" but isn't exact
  if (lower.startsWith('mural (per m') && value !== CORRECT_LABEL) return true;
  return false;
}

async function gqlOnce(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(new Error(`non-JSON response (${res.statusCode}): ${String(d).slice(0,80)}`)); } });
    });
    req.on('error', reject);
    req.write(body);
    req.end();
  });
}

// TK-10029: Shopify's gateway intermittently returns a non-JSON "upstream connect error"
// (transient 502/503) that would otherwise crash a long apply run mid-flight. Retry transient
// failures with exponential backoff; surface a permanent failure only after N attempts.
async function gql(query, vars) {
  let lastErr;
  for (let attempt = 1; attempt <= 6; attempt++) {
    try { return await gqlOnce(query, vars); }
    catch (e) {
      lastErr = e;
      if (attempt === 6) break;
      const backoff = Math.min(8000, 400 * 2 ** (attempt - 1));
      console.log(`  [gql retry ${attempt}/5] ${e.message} — waiting ${backoff}ms`);
      await sleep(backoff);
    }
  }
  throw lastErr;
}

async function sleep(ms) { return 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 { 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;
}

function isSampleProduct(product) {
  // Memo samples and sample-only products should not be relabeled to "Sold Per Square Meter"
  const titleLower = product.title.toLowerCase();
  return titleLower.includes('memo sample') || titleLower.includes(' sample') || titleLower.endsWith('sample');
}

function needsFix(product) {
  if (isSampleProduct(product)) return false;
  for (const v of product.variants.edges.map(e => e.node)) {
    for (const o of v.selectedOptions) {
      if (isWrongMuralLabel(o.value)) return true;
    }
  }
  return false;
}

async function relabelProduct(product) {
  // Find the option that actually carries a wrong mural label (robust — not name-bound).
  const optionToFix =
    product.options.find(o => o.values.some(v => isWrongMuralLabel(v))) ||
    product.options.find(o => o.name === 'Size' || o.name === 'Title');
  if (!optionToFix) {
    console.log(`  SKIP ${product.id} — no mural option found`);
    return false;
  }

  const wrongValues = optionToFix.values.filter(v => isWrongMuralLabel(v));
  if (!wrongValues.length) { console.log(`  SKIP ${product.title} — already correct`); return false; }

  const canonicalAlreadyPresent = optionToFix.values.includes(CORRECT_LABEL);

  // NON-DESTRUCTIVE plan (renames only — never deletes a live variant, never creates a
  // duplicate canonical). "Default Title" is ambiguous: sometimes it IS the mural variant,
  // sometimes a leftover orphan. So:
  //   - if the canonical is already on the option → the unit label is ALREADY correct;
  //     leave the product untouched and just FLAG any redundant wrong value for review.
  //   - else rename exactly ONE mural-unit value to canonical (prefer a real unit label
  //     over generic "Default Title"); FLAG any additional wrong values for review rather
  //     than renaming them (which would duplicate) or deleting them (destructive).
  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) {
    // Unit label already canonical — not a unit-label defect. Report the orphan, don't touch.
    console.log(`  REVIEW ${product.title} — canonical already present; redundant value(s) ${JSON.stringify(toReview)} left for manual cleanup (no auto-delete)`);
    return 'review';
  }

  console.log(`  FIX ${product.title}`);
  console.log(`    Option "${optionToFix.name}": ${JSON.stringify(optionToFix.values)}`);
  console.log(`      RENAME ["${primary}"] → "${CORRECT_LABEL}"`);
  if (toReview.length) console.log(`      REVIEW leftover ${JSON.stringify(toReview)} — NOT modified (avoids destructive delete / duplicate); flag for manual cleanup`);

  if (DRY) return true;

  // Fetch option value IDs (values themselves have no id in the list query).
  const detailQ = `query { product(id: "${product.id}") { options { id name optionValues { id name } } } }`;
  const detail = await gql(detailQ);
  const opt = detail.data.product.options.find(o => o.id === optionToFix.id);
  if (!opt) { console.log(`  ERR can't fetch option values for ${product.id}`); return false; }

  const optionValuesToUpdate = opt.optionValues
    .filter(ov => ov.name === primary)
    .map(ov => ({ id: ov.id, name: CORRECT_LABEL }));

  if (!optionValuesToUpdate.length) { console.log(`  SKIP already done`); return false; }

  const mutation = `
    mutation UpdateOption($productId: ID!, $option: OptionUpdateInput!,
                          $optionValuesToUpdate: [OptionValueUpdateInput!]!) {
      productOptionUpdate(productId: $productId, option: $option,
                          optionValuesToUpdate: $optionValuesToUpdate) {
        product { id }
        userErrors { field message }
      }
    }
  `;

  const res = await gql(mutation, {
    productId: product.id,
    option: { id: optionToFix.id, name: optionToFix.name },
    optionValuesToUpdate,
  });

  const errs = res.data?.productOptionUpdate?.userErrors;
  if (errs && errs.length) {
    console.log(`  ERR ${product.id}: ${errs.map(e => e.message).join('; ')}`);
    return false;
  }
  return true;
}

(async () => {
  console.log(`[relabel-units] mode=${DRY ? 'DRY-RUN' : 'LIVE'} limit=${LIMIT === Infinity ? 'none' : LIMIT}`);
  console.log('[relabel-units] fetching all Rebel Walls products...');
  const all = await fetchAll();
  console.log(`[relabel-units] fetched ${all.length} products`);

  const toFix = all.filter(needsFix);
  console.log(`[relabel-units] ${toFix.length} products need relabel`);

  const counts = {};
  for (const p of all) {
    for (const v of p.variants.edges.map(e => e.node)) {
      for (const o of v.selectedOptions) {
        counts[o.value] = (counts[o.value] || 0) + 1;
      }
    }
  }
  const mural = Object.entries(counts).filter(([k]) => k !== 'Sample' && k !== 'Default Title').sort((a,b) => b[1]-a[1]);
  console.log('[relabel-units] current mural variant value distribution:');
  for (const [k, v] of mural) console.log(`  ${v.toString().padStart(5)}  "${k}"`);

  if (!toFix.length) { console.log('[relabel-units] nothing to fix — done'); return; }

  let fixed = 0, skipped = 0, review = 0;
  for (const p of toFix) {
    if (fixed >= LIMIT) { console.log(`[relabel-units] hit limit ${LIMIT}`); break; }
    const did = await relabelProduct(p);
    if (did === 'review') { review++; }
    else if (did) { fixed++; await sleep(DRY ? 0 : 500); }
    else { skipped++; }
  }

  console.log(`[relabel-units] done. fixed=${fixed} skipped=${skipped} review=${review} dry=${DRY}`);
  if (review) console.log(`[relabel-units] ${review} products FLAGGED for manual review (canonical already present + a redundant orphan value; not auto-modified). See REVIEW lines above.`);
})().catch(e => { console.error('[relabel-units] FATAL', e.message); process.exit(1); });