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