← back to Dwjs Bolt Single Roll Fix
apply-single-roll-fix.js
167 lines
#!/usr/bin/env node
/*
* apply-single-roll-fix.js — stop the DWJS peel & stick below-cost leak.
*
* Per variant (38 rows in mapping.csv):
* 1. rename option value "Price Per Bolt" -> "Price Per Single Roll"
* 2. set price = $31.86 (single-roll retail)
* 3. set product order-min metafields: min=2, step=2, unit display
* so every order consumes a full $34 double-roll bolt (no stranded roll).
*
* SAFETY (gated-work pattern):
* - DEFAULT = read-only. Snapshots current state to rollback.json + prints the
* plan. Writes NOTHING to Shopify.
* - LIVE writes require BOTH --execute AND --i-have-steve-go.
* - Snapshot-first: rollback.json is written before any mutation.
* - Fail-closed: any GraphQL userError aborts the whole run.
* - Rollback verb: --rollback rollback.json --execute --i-have-steve-go.
*
* Token: SHOPIFY_ADMIN_TOKEN (write_products) from the DW shopify/.env. Never logged.
* Cost: $0 (Admin GraphQL has no per-call charge).
*/
'use strict';
const fs = require('fs');
const path = require('path');
const SHOP_ENV = path.join(process.env.HOME, 'Projects/Designer-Wallcoverings/shopify/.env');
const API = '2024-10';
const NEW_OPTION_VALUE = 'Price Per Single Roll';
const SINGLE_PRICE = '31.86';
const SINGLE_FLOOR = 30.77; // cost $17/roll / .65 / .85 — never price below this
const MIN_QTY = '2', STEP_QTY = '2'; // even qty only => whole bolts
const UNIT_DISPLAY = 'Priced Per Single Roll';
function env(k) {
try {
const line = fs.readFileSync(SHOP_ENV, 'utf8').split('\n').find(l => l.startsWith(k + '='));
return line ? line.slice(k.length + 1).trim() : (process.env[k] || null);
} catch { return process.env[k] || null; }
}
const TOKEN = env('SHOPIFY_ADMIN_TOKEN');
const STORE = env('SHOPIFY_STORE_DOMAIN') || 'designer-laboratory-sandbox.myshopify.com';
const args = process.argv.slice(2);
const has = f => args.includes(f);
const val = f => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : null; };
const EXECUTE = has('--execute') && has('--i-have-steve-go');
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function gql(query, variables) {
const res = await fetch(`https://${STORE}/admin/api/${API}/graphql.json`, {
method: 'POST',
headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
});
const j = await res.json();
if (j.errors) throw new Error('GraphQL: ' + JSON.stringify(j.errors));
return j.data;
}
function readMapping() {
const csv = fs.readFileSync(path.join(__dirname, 'mapping.csv'), 'utf8').trim().split('\n');
const head = csv[0].split(',');
return csv.slice(1).map(line => {
const c = line.split(',');
return Object.fromEntries(head.map((h, i) => [h, c[i]]));
});
}
const PRODUCT_Q = `query($id:ID!){ product(id:$id){ id handle
options{ id name optionValues{ id name } }
variants(first:20){ edges{ node{ id sku price selectedOptions{name value} } } }
metafields(first:50){ edges{ node{ namespace key value type } } } } }`;
async function snapshotProduct(pid) {
const d = await gql(PRODUCT_Q, { id: `gid://shopify/Product/${pid}` });
return d.product;
}
const OPTION_UPDATE = `mutation($productId:ID!,$option:OptionUpdateInput!,$vals:[OptionValueUpdateInput!]){
productOptionUpdate(productId:$productId, option:$option, optionValuesToUpdate:$vals){
userErrors{ field message } } }`;
const VARIANTS_UPDATE = `mutation($productId:ID!,$variants:[ProductVariantsBulkInput!]!){
productVariantsBulkUpdate(productId:$productId, variants:$variants){ userErrors{ field message } } }`;
const METAFIELDS_SET = `mutation($mf:[MetafieldsSetInput!]!){
metafieldsSet(metafields:$mf){ userErrors{ field message } } }`;
function die(m){ console.error('REFUSING / ABORT: ' + m); process.exit(3); }
function checkErrs(d, where){ const e = (d && Object.values(d)[0] || {}).userErrors || []; if(e.length) die(`${where}: ${JSON.stringify(e)}`); }
async function main() {
if (!TOKEN) die('no SHOPIFY_ADMIN_TOKEN');
if (has('--execute') && !has('--i-have-steve-go'))
die('--execute requires --i-have-steve-go (explicit Steve approval token).');
const rows = readMapping();
console.log(`mode: ${EXECUTE ? 'LIVE WRITE' : 'read-only (snapshot + plan)'} store=${STORE} rows=${rows.length}`);
// ---- ROLLBACK verb ----
const rbFile = val('--rollback');
if (rbFile) {
const snap = JSON.parse(fs.readFileSync(rbFile, 'utf8'));
console.log(`rollback: restoring ${snap.length} products from ${rbFile}`);
if (!EXECUTE) return console.log('(read-only; pass --execute --i-have-steve-go to restore)');
for (const s of snap) {
checkErrs(await gql(OPTION_UPDATE, { productId:`gid://shopify/Product/${s.product_id}`,
option:{ id:s.option_id }, vals:[{ id:s.option_value_id, name:s.old_option_value }] }), 'rollback option');
checkErrs(await gql(VARIANTS_UPDATE, { productId:`gid://shopify/Product/${s.product_id}`,
variants:[{ id:`gid://shopify/ProductVariant/${s.variant_id}`, price:s.old_price }] }), 'rollback price');
await sleep(600);
}
return console.log('rollback complete.');
}
// ---- FORWARD: snapshot-first ----
const snapshot = [];
const plan = [];
for (const r of rows) {
if (Number(r.new_price) < SINGLE_FLOOR) die(`${r.dw_sku}: new_price ${r.new_price} < floor ${SINGLE_FLOOR}`);
const p = await snapshotProduct(r.product_id);
const sizeOpt = p.options.find(o => o.name === 'Size') || p.options[0];
const boltVal = sizeOpt.optionValues.find(v => v.name === 'Price Per Bolt');
const variant = p.variants.edges.map(e => e.node).find(v => v.sku === r.dw_sku);
const mfMin = p.metafields.edges.find(e => e.node.namespace==='global' && e.node.key==='v_prods_quantity_order_min');
const mfStep = p.metafields.edges.find(e => e.node.namespace==='global' && e.node.key==='v_prods_quantity_order_units');
snapshot.push({
dw_sku:r.dw_sku, product_id:r.product_id, handle:p.handle,
option_id:sizeOpt.id, option_value_id: boltVal ? boltVal.id : null,
old_option_value: boltVal ? boltVal.name : '(none-found)',
variant_id: variant ? variant.id.split('/').pop() : r.variant_id,
old_price: variant ? variant.price : null,
old_min: mfMin ? mfMin.node.value : null, old_step: mfStep ? mfStep.node.value : null,
});
plan.push(`${r.dw_sku}: "${boltVal?boltVal.name:'?'}"→"${NEW_OPTION_VALUE}" $${variant?variant.price:'?'}→$${SINGLE_PRICE} min/step→${MIN_QTY}/${STEP_QTY}`);
await sleep(250);
}
fs.writeFileSync(path.join(__dirname, 'rollback.json'), JSON.stringify(snapshot, null, 2));
console.log(`\nsnapshot written: rollback.json (${snapshot.length} products)`);
console.log('plan:\n ' + plan.join('\n '));
if (!EXECUTE) {
console.log('\nread-only. No Shopify writes performed.');
console.log('To apply (gated): node apply-single-roll-fix.js --execute --i-have-steve-go');
return;
}
// ---- LIVE writes (snapshot already on disk) ----
let n = 0;
for (const s of snapshot) {
if (!s.option_value_id) { console.warn(`skip ${s.dw_sku}: no "Price Per Bolt" value found`); continue; }
checkErrs(await gql(OPTION_UPDATE, { productId:`gid://shopify/Product/${s.product_id}`,
option:{ id:s.option_id }, vals:[{ id:s.option_value_id, name:NEW_OPTION_VALUE }] }), `${s.dw_sku} option`);
checkErrs(await gql(VARIANTS_UPDATE, { productId:`gid://shopify/Product/${s.product_id}`,
variants:[{ id:`gid://shopify/ProductVariant/${s.variant_id}`, price:SINGLE_PRICE }] }), `${s.dw_sku} price`);
checkErrs(await gql(METAFIELDS_SET, { mf:[
{ ownerId:`gid://shopify/Product/${s.product_id}`, namespace:'global', key:'v_prods_quantity_order_min', type:'single_line_text_field', value:MIN_QTY },
{ ownerId:`gid://shopify/Product/${s.product_id}`, namespace:'global', key:'v_prods_quantity_order_units', type:'single_line_text_field', value:STEP_QTY },
{ ownerId:`gid://shopify/Product/${s.product_id}`, namespace:'global', key:'unit_of_measure', type:'single_line_text_field', value:UNIT_DISPLAY },
]}), `${s.dw_sku} metafields`);
n++;
if (n % 25 === 0) { console.log(` …${n} done, pausing 90s (DW bulk rule)`); await sleep(90_000); }
else await sleep(700);
}
console.log(`\nLIVE WRITE complete: ${n} products relabeled + repriced + min-2 enforced.`);
console.log('rollback: node apply-single-roll-fix.js --rollback rollback.json --execute --i-have-steve-go');
}
main().catch(e => die(e.message));