← 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); });