← back to Sanderson Onboard

tk10873/apply_labeling.mjs

408 lines

#!/usr/bin/env node
// apply_labeling.mjs — TK-10873 Zoffany wide-width UNIT-LABELING pass (the pair to the v7 reprice).
//
// ─────────────────────────────────────────────────────────────────────────────
// DESIGN CONTRACT — restore-map-FIRST, fail-closed, reversible, ledgered.
// Mirrors reprice_live_runbook.mjs exactly: the LIVE write (customer-facing COPY:
// variant title + PDP description) is GATED behind BOTH `--apply` and
// `--i-understand-this-is-live`, run by Steve as a separate `!` step. The DEFAULT
// run is a DRY-RUN that resolves each product LIVE (read-only), prints the planned
// label changes, and writes NOTHING to Shopify.
// ─────────────────────────────────────────────────────────────────────────────
//
// Spec: zoffany_widewidth_labeling_spec.json (45 rows / 20 patterns). Per SKU:
//   1) Rename the SELLABLE variant's option value "Default Title" -> "Per Yard".
//      (These are single-option "Title" products with 2 variants: the sellable
//       one carries option-value "Default Title", the memo carries "Sample". We
//       rename ONLY the sellable option value via productOptionUpdate; the Sample
//       value is never touched, so the memo variant + its $4.25 price are safe.)
//   2) Append the spec's pdp_line to the product descriptionHtml (idempotent: if
//      the exact line is already present, the description is left unchanged).
//
// Modes
//   (default)  plan   — DRY-RUN. Resolve each row LIVE (read-only GET), verify the
//                        expected pre-state (ACTIVE, single "Title" option, a
//                        "Default Title" sellable value, a "Sample" value present),
//                        and PRINT the planned changes. Writes a PREVIEW restore
//                        map. ZERO Shopify writes. ABORT-report (not exit) on any
//                        row that fails to resolve so the whole set is visible.
//   --apply    LIVE   — Phase 1 RESOLVE+SNAPSHOT: live-read every row, capture the
//                        COMPLETE restore map {product_id, option_id,
//                        sellable_value_id, old_value_name, new_value_name,
//                        old_descriptionHtml, new_descriptionHtml}. Persist (+fsync)
//                        BEFORE any write. FAIL-CLOSED: if ANY row fails to resolve
//                        or its live state != expected, ABORT with zero writes.
//                        Phase 2 WRITE: per product, productOptionUpdate (rename the
//                        sellable value) then productUpdate (descriptionHtml), each
//                        readback-verified, appended to the reversible ledger.
//   --revert <map.json> LIVE — read a restore map and set every option value name +
//                        descriptionHtml back to old_* (the undo). Readback + ledger.
//
// Safety rails (identical philosophy to the reprice runbook):
//   • --apply requires --i-understand-this-is-live (footgun guard).
//   • ACTIVE-only (live status must be ACTIVE); exactly-one sellable option value.
//   • The Sample variant / its value / its $4.25 price are NEVER in any write set.
//   • Restore map path is printed BEFORE the first write; run aborts if it can't be
//     written+fsync'd.
//   • Batches pace to respect Shopify's GraphQL cost throttle.
import fs from 'node:fs';
import { execFileSync } from 'node:child_process';

const DIR = new URL('.', import.meta.url).pathname;
const SPEC_IN = process.env.LABELING_SPEC ? `${DIR}${process.env.LABELING_SPEC}` : `${DIR}zoffany_widewidth_labeling_spec.json`;
const PLAN_OUT = `${DIR}apply_labeling.live_plan.json`;
const PREVIEW_OUT = `${DIR}apply_labeling.restore_map.preview.json`;
const RESTORE_OUT = `${DIR}apply_labeling.restore_map.live.json`; // populated ONLY by --apply, before writes
const LEDGER = `${process.env.HOME}/.claude/yolo-queue/executed-reversible/ledger.jsonl`;

const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';
const BATCH = Number(process.env.LABELING_BATCH || 25);
const SAMPLE_PRICE = 4.25;
const NEW_VALUE = 'Per Yard';
const OLD_VALUE = 'Default Title';

// GraphQL query width limits (must cover all real products; bump if Shopify returns truncation warnings)
const OPTIONS_FIRST = 5;
const VARIANTS_FIRST = 20;

// Throttle pacing: pause when cost bucket is low
const THROTTLE_MIN_AVAILABLE = 200;
const THROTTLE_PAUSE_MS = 1200;

const argv = process.argv.slice(2);
const APPLY = argv.includes('--apply');
const LIVE_OK = argv.includes('--i-understand-this-is-live');
const REVERT_IDX = argv.indexOf('--revert');
const REVERT_MAP = REVERT_IDX >= 0 ? argv[REVERT_IDX + 1] : null;

// ── mirror read (read-only, local, $0) — resolves mfr_sku -> shopify product id ─
function psql(q) {
  return execFileSync('psql', ['-h', '/tmp', '-d', 'dw_unified', '-tAF\t', '-c', q], { encoding: 'utf8' });
}
function loadMirror(mfrSkus) {
  const inlist = mfrSkus.map(s => `'${String(s).replace(/'/g, "''")}'`).join(',');
  const q = `SELECT mfr_sku, vendor, shopify_id, sku, status
             FROM shopify_products WHERE mfr_sku IN (${inlist})`;
  const m = new Map();
  for (const line of psql(q).trim().split('\n').filter(Boolean)) {
    const [mfr_sku, vendor, shopify_id, sku, status] = line.split('\t');
    m.set(mfr_sku, { mfr_sku, vendor, shopify_id, sku, status });
  }
  return m;
}

// ── the labeling set: 45 rows from the spec ───────────────────────────────────
function loadSpecRows() {
  const spec = JSON.parse(fs.readFileSync(SPEC_IN, 'utf8'));
  const rows = spec.rows || spec;
  return rows.map(r => ({
    mfr_sku: r.mfr_sku,
    pattern: r.pattern,
    variant_label: r.variant_label || NEW_VALUE,
    pdp_line: r.pdp_line,
  }));
}

// ── Shopify Admin GraphQL (used ONLY by --apply / --revert) ───────────────────
function token() {
  const t = process.env.SHOPIFY_ADMIN_TOKEN;
  if (!t) throw new Error('SHOPIFY_ADMIN_TOKEN not set — source ~/Projects/secrets-manager/.env before a live run');
  return t;
}
async function gql(query, variables) {
  const res = await fetch(`https://${SHOP}/admin/api/${API}/graphql.json`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': token() },
    body: JSON.stringify({ query, variables }),
  });
  const j = await res.json();
  if (j.errors) throw new Error('GraphQL errors: ' + JSON.stringify(j.errors));
  // pace against the throttle: if we're low on the cost bucket, breathe.
  const throttle = j.extensions?.cost?.throttleStatus;
  if (throttle && throttle.currentlyAvailable < THROTTLE_MIN_AVAILABLE) await new Promise(r => setTimeout(r, THROTTLE_PAUSE_MS));
  return j.data;
}

// Live-read a product's options, variants, and description.
const PRODUCT_Q = `query($id: ID!) {
  product(id: $id) {
    id status title descriptionHtml
    options(first: ${OPTIONS_FIRST}) { id name position optionValues { id name } }
    variants(first: ${VARIANTS_FIRST}) { nodes { id sku title price selectedOptions { name value } } }
  }
}`;

// Resolve the single sellable option value to rename. Enforces the expected shape:
//   • one option named "Title"
//   • that option has a "Sample" value (the memo variant we must NEVER touch)
//   • exactly one non-sample sellable variant; its "Title" option value is the one we
//     rename to NEW_VALUE ("Per Yard"), whatever it currently is ("Default Title",
//     "Sold Per Roll", etc.) — EXCEPT that if it is ALREADY NEW_VALUE the row is a
//     no-op (already labeled).
// This is deliberately tolerant of the ACTUAL live pre-state: an earlier pass labeled
// most of these "Sold Per Roll" (the wrong unit for per-yard goods) rather than leaving
// them "Default Title", so we key off the sellable VARIANT's own option value, not a
// hard-coded literal. Returns { option_id, sellable_value_id, old_value_name } or throws.
function resolveSellableValue(prod) {
  if (!prod) throw new Error('product not found live');
  if (prod.status !== 'ACTIVE') throw new Error(`live status ${prod.status} (expected ACTIVE)`);
  const titleOpts = prod.options.filter(o => o.name === 'Title');
  if (titleOpts.length !== 1) throw new Error(`expected 1 "Title" option, found ${titleOpts.length}`);
  const opt = titleOpts[0];
  const sampleVal = opt.optionValues.find(v => v.name === 'Sample');
  if (!sampleVal) throw new Error('no "Sample" option value present (unexpected structure)');
  // the sellable variant: exactly one non-sample, priced != sample.
  const sellableVars = prod.variants.nodes.filter(v =>
    !String(v.sku || '').endsWith('-Sample') &&
    !/sample/i.test(v.title || '') &&
    Number(v.price) !== SAMPLE_PRICE);
  if (sellableVars.length !== 1) throw new Error(`expected 1 sellable variant, found ${sellableVars.length}`);
  const sv = sellableVars[0];
  const svOptVal = (sv.selectedOptions || []).find(so => so.name === 'Title')?.value;
  if (svOptVal == null) throw new Error('sellable variant has no "Title" option value');
  if (svOptVal === 'Sample') throw new Error('sellable variant resolved to the Sample value (guard tripped)');
  // find the option-value record backing that sellable value.
  const sellableVal = opt.optionValues.find(v => v.name === svOptVal);
  if (!sellableVal) throw new Error(`sellable option value "${svOptVal}" not found in the option's values`);
  if (sellableVal.id === sampleVal.id) throw new Error('sellable value id equals the Sample value id (guard tripped)');
  return { option_id: opt.id, sellable_value_id: sellableVal.id, old_value_name: sellableVal.name };
}

// Compute the new descriptionHtml: append the pdp_line as its own <p> unless already present.
function appendPdpLine(oldHtml, pdpLine) {
  const html = oldHtml || '';
  if (html.includes(pdpLine)) return { changed: false, newHtml: html };
  const sep = html.trim().length ? '\n' : '';
  return { changed: true, newHtml: `${html}${sep}<p>${pdpLine}</p>` };
}

const OPTION_UPDATE_M = `mutation($productId: ID!, $option: OptionUpdateInput!, $optionValuesToUpdate: [OptionValueUpdateInput!]) {
  productOptionUpdate(productId: $productId, option: $option, optionValuesToUpdate: $optionValuesToUpdate) {
    product { id options(first: ${OPTIONS_FIRST}) { id name optionValues { id name } } }
    userErrors { field message }
  }
}`;

const PRODUCT_UPDATE_M = `mutation($input: ProductInput!) {
  productUpdate(input: $input) { product { id descriptionHtml } userErrors { field message } }
}`;

function ledger(entry) { fs.appendFileSync(LEDGER, JSON.stringify(entry) + '\n'); }
function writeFsync(path, data) {
  const fd = fs.openSync(path, 'w');
  fs.writeSync(fd, data);
  fs.fsyncSync(fd);
  fs.closeSync(fd);
}

// Resolve every row to its live pre-state. Used by BOTH plan and apply (single code path
// = the dry-run proves exactly what apply will do). Returns { resolved, failures }.
async function resolveAll(rows, mirror) {
  const resolved = [], failures = [];
  for (const r of rows) {
    const m = mirror.get(r.mfr_sku);
    if (!m) { failures.push({ mfr_sku: r.mfr_sku, why: 'no mirror product' }); continue; }
    if (m.status !== 'ACTIVE') { failures.push({ mfr_sku: r.mfr_sku, why: `mirror status ${m.status}` }); continue; }
    let data;
    try { data = await gql(PRODUCT_Q, { id: m.shopify_id }); }
    catch (e) { failures.push({ mfr_sku: r.mfr_sku, why: 'GET failed: ' + e.message }); continue; }
    const prod = data.product;
    let sel;
    try { sel = resolveSellableValue(prod); }
    catch (e) { failures.push({ mfr_sku: r.mfr_sku, why: e.message }); continue; }
    const { changed, newHtml } = appendPdpLine(prod.descriptionHtml, r.pdp_line);
    resolved.push({
      mfr_sku: r.mfr_sku, pattern: r.pattern, product_id: prod.id, product_title: prod.title,
      option_id: sel.option_id, sellable_value_id: sel.sellable_value_id,
      old_value_name: sel.old_value_name, new_value_name: r.variant_label,
      old_descriptionHtml: prod.descriptionHtml || '',
      new_descriptionHtml: newHtml,
      desc_changed: changed,
      value_changed: sel.old_value_name !== r.variant_label,
    });
  }
  return { resolved, failures };
}

// ── MODE: plan (DRY-RUN — resolves live read-only, writes NOTHING to Shopify) ──
async function runPlan() {
  const rows = loadSpecRows();
  const mirror = loadMirror(rows.map(r => r.mfr_sku));
  console.log('=== Zoffany wide-width UNIT-LABELING — PLAN (DRY-RUN, read-only GETs, ZERO writes) ===');
  console.log(`spec rows: ${rows.length}   (spec: ${SPEC_IN.split('/').pop()})`);
  const { resolved, failures } = await resolveAll(rows, mirror);

  // PREVIEW restore map — same shape as the live map materializes.
  const preview = resolved.map(p => ({
    mfr_sku: p.mfr_sku, product_id: p.product_id, option_id: p.option_id,
    sellable_value_id: p.sellable_value_id,
    old_value_name: p.old_value_name, new_value_name: p.new_value_name,
    old_descriptionHtml: p.old_descriptionHtml, new_descriptionHtml: p.new_descriptionHtml,
    _note: 'PREVIEW only — the live --apply run re-reads + captures this map from a live read BEFORE any write.',
  }));
  fs.writeFileSync(PREVIEW_OUT, JSON.stringify({ ticket: 'TK-10873', mode: 'preview', count: preview.length, rows: preview }, null, 2));
  fs.writeFileSync(PLAN_OUT, JSON.stringify(resolved.map(p => ({
    mfr_sku: p.mfr_sku, pattern: p.pattern, product_id: p.product_id, product_title: p.product_title,
    rename: `"${p.old_value_name}" -> "${p.new_value_name}"`, value_changed: p.value_changed,
    desc_changed: p.desc_changed,
  })), null, 2));

  console.log(`resolved LIVE to an ACTIVE product w/ a single sellable option value: ${resolved.length}/${rows.length}`);
  const renameCount = resolved.filter(p => p.value_changed).length;
  const descCount = resolved.filter(p => p.desc_changed).length;
  // show the distinct current sellable-value names we'd rename FROM (real pre-state varies).
  const fromNames = [...new Set(resolved.filter(p => p.value_changed).map(p => p.old_value_name))];
  console.log(`  ├─ would rename sellable option value -> "${NEW_VALUE}": ${renameCount}  (from: ${fromNames.map(n => `"${n}"`).join(', ') || 'n/a'})`);
  console.log(`  └─ would append pdp_line to descriptionHtml: ${descCount} (already-present, skipped: ${resolved.length - descCount})`);
  console.log('\nsample of planned changes (first 6):');
  for (const p of resolved.slice(0, 6)) {
    console.log(`  ${p.mfr_sku}  "${p.product_title}"  value "${p.old_value_name}"->"${p.new_value_name}"  desc+${p.desc_changed ? 'yes' : 'no'}`);
  }
  if (failures.length) {
    console.log(`\n!! ${failures.length} row(s) FAILED to resolve — a live --apply would ABORT with zero writes:`);
    console.log(JSON.stringify(failures, null, 2));
  } else {
    console.log(`\nALL ${resolved.length} rows resolved clean — restore-map-first apply is wired and ready.`);
  }
  console.log(`\nartifacts:\n  ${PLAN_OUT}\n  ${PREVIEW_OUT}`);
  console.log('\nThis run made NO Shopify writes. The live labeling is:');
  console.log('  node apply_labeling.mjs --apply --i-understand-this-is-live');
  console.log('  (run by Steve via `!`, with SHOPIFY_ADMIN_TOKEN sourced.)');
  process.exit(failures.length ? 5 : 0);
}

// ── MODE: apply (LIVE — restore-map-first) ────────────────────────────────────
async function runApply() {
  if (!LIVE_OK) {
    console.error('REFUSING: --apply requires --i-understand-this-is-live (customer-facing copy write).');
    process.exit(2);
  }
  const rows = loadSpecRows();
  const mirror = loadMirror(rows.map(r => r.mfr_sku));
  console.log(`[apply] ${rows.length} spec rows. Resolving live pre-state…`);

  // ── PHASE 1: RESOLVE + SNAPSHOT (no writes) ─────────────────────────────────
  const { resolved, failures } = await resolveAll(rows, mirror);

  // FAIL-CLOSED: any resolution failure aborts BEFORE a single write.
  if (failures.length) {
    console.error(`\n[apply] ABORTED — ${failures.length} rows failed to resolve. ZERO writes made.`);
    console.error(JSON.stringify(failures.slice(0, 30), null, 2));
    process.exit(3);
  }

  // Persist the COMPLETE restore map (+fsync) BEFORE the first write. This IS the undo.
  writeFsync(RESTORE_OUT, JSON.stringify({
    ticket: 'TK-10873', generated_at: new Date().toISOString(),
    shop: SHOP, count: resolved.length,
    rows: resolved.map(p => ({
      mfr_sku: p.mfr_sku, product_id: p.product_id, option_id: p.option_id,
      sellable_value_id: p.sellable_value_id,
      old_value_name: p.old_value_name, new_value_name: p.new_value_name,
      old_descriptionHtml: p.old_descriptionHtml, new_descriptionHtml: p.new_descriptionHtml,
    })),
  }, null, 2));
  console.log(`\n[apply] restore map persisted BEFORE any write → ${RESTORE_OUT} (${resolved.length} rows)`);
  console.log(`[apply] undo at any time: node apply_labeling.mjs --revert ${RESTORE_OUT} --i-understand-this-is-live`);

  // ── PHASE 2: WRITE (batched, verify-readback, ledger) ───────────────────────
  // Every network call is wrapped so a transient error lands in errs[] instead of
  // throwing past the loop; the ledger always fires. The restore map covers the undo.
  let ok = 0, errs = [];
  for (let i = 0; i < resolved.length; i += BATCH) {
    const batch = resolved.slice(i, i + BATCH);
    for (const row of batch) {
      try {
        // (a) rename the sellable option value: "Default Title" -> "Per Yard"
        if (row.value_changed) {
          const d = await gql(OPTION_UPDATE_M, {
            productId: row.product_id,
            option: { id: row.option_id },
            optionValuesToUpdate: [{ id: row.sellable_value_id, name: row.new_value_name }],
          });
          const ue = d.productOptionUpdate.userErrors;
          if (ue && ue.length) { errs.push({ mfr_sku: row.mfr_sku, step: 'optionUpdate', userErrors: ue }); continue; }
          const vals = d.productOptionUpdate.product?.options?.find(o => o.id === row.option_id)?.optionValues || [];
          if (!vals.some(v => v.id === row.sellable_value_id && v.name === row.new_value_name)) {
            errs.push({ mfr_sku: row.mfr_sku, step: 'optionUpdate', why: 'readback value name mismatch' }); continue;
          }
        }
        // (b) append the pdp_line to descriptionHtml
        if (row.desc_changed) {
          const d = await gql(PRODUCT_UPDATE_M, { input: { id: row.product_id, descriptionHtml: row.new_descriptionHtml } });
          const ue = d.productUpdate.userErrors;
          if (ue && ue.length) { errs.push({ mfr_sku: row.mfr_sku, step: 'productUpdate', userErrors: ue }); continue; }
          const wrote = d.productUpdate.product?.descriptionHtml;
          if (wrote !== row.new_descriptionHtml) {
            errs.push({ mfr_sku: row.mfr_sku, step: 'productUpdate', why: 'readback descriptionHtml mismatch' }); continue;
          }
        }
        ok++;
      } catch (e) {
        errs.push({ mfr_sku: row.mfr_sku, why: 'write threw: ' + e.message });
        continue;
      }
    }
    console.log(`[apply] batch ${Math.floor(i / BATCH) + 1}: ${ok}/${resolved.length} labeled  (errors so far: ${errs.length})`);
  }

  ledger({
    ts: new Date().toISOString(), agent: 'vp-dw-commerce', ticket: 'TK-10873',
    action: `Zoffany wide-width unit-labeling — rename sellable option value "${OLD_VALUE}"->"${NEW_VALUE}" + append per-yard PDP line on ${ok} ACTIVE products`,
    blast_radius: ok,
    undo_cmd: `node ${DIR}apply_labeling.mjs --revert ${RESTORE_OUT} --i-understand-this-is-live`,
    verify: `restore map ${RESTORE_OUT}; readback-verified each write`,
  });

  console.log(`\n[apply] DONE. labeled+verified=${ok}  errors=${errs.length}`);
  if (errs.length) console.log(JSON.stringify(errs.slice(0, 20), null, 2));
  process.exit(errs.length ? 1 : 0);
}

// ── MODE: revert (LIVE undo) ──────────────────────────────────────────────────
async function runRevert() {
  if (!LIVE_OK) { console.error('REFUSING: --revert requires --i-understand-this-is-live.'); process.exit(2); }
  const map = JSON.parse(fs.readFileSync(REVERT_MAP, 'utf8'));
  const rows = map.rows || map;
  console.log(`[revert] restoring ${rows.length} products' option value + description from ${REVERT_MAP}…`);
  let ok = 0, errs = [];
  for (const row of rows) {
    try {
      // (a) restore the option value name
      if (row.old_value_name !== row.new_value_name) {
        const d = await gql(OPTION_UPDATE_M, {
          productId: row.product_id,
          option: { id: row.option_id },
          optionValuesToUpdate: [{ id: row.sellable_value_id, name: row.old_value_name }],
        });
        const ue = d.productOptionUpdate.userErrors;
        if (ue && ue.length) { errs.push({ mfr_sku: row.mfr_sku, step: 'optionUpdate', userErrors: ue }); continue; }
      }
      // (b) restore the description
      if (row.old_descriptionHtml !== row.new_descriptionHtml) {
        const d = await gql(PRODUCT_UPDATE_M, { input: { id: row.product_id, descriptionHtml: row.old_descriptionHtml } });
        const ue = d.productUpdate.userErrors;
        if (ue && ue.length) { errs.push({ mfr_sku: row.mfr_sku, step: 'productUpdate', userErrors: ue }); continue; }
      }
      ok++;
    } catch (e) { errs.push({ mfr_sku: row.mfr_sku, why: 'revert threw: ' + e.message }); continue; }
  }
  ledger({
    ts: new Date().toISOString(), agent: 'vp-dw-commerce', ticket: 'TK-10873',
    action: `REVERT Zoffany wide-width unit-labeling — restored ${ok} products' option value + description`,
    blast_radius: ok, undo_cmd: 're-run --apply to redo', verify: `from ${REVERT_MAP}`,
  });
  console.log(`[revert] DONE. restored=${ok}  errors=${errs.length}`);
  if (errs.length) console.log(JSON.stringify(errs.slice(0, 20), null, 2));
  process.exit(errs.length ? 1 : 0);
}

// ── dispatch ──────────────────────────────────────────────────────────────────
(async () => {
  if (REVERT_IDX >= 0 && !REVERT_MAP) throw new Error('--revert requires a restore-map path argument');
  if (REVERT_MAP) return runRevert();
  if (APPLY) return runApply();
  return runPlan();
})().catch(e => { console.error('FATAL:', e.message); process.exit(1); });