[object Object]

← back to Tk 10965 Zero Price Analysis

Add apply-fix.mjs (restore-map-first, canary-batched, rollback) — needs write_inventory scope

7eb582bc408dfdad2a95cabc89ec22abeb31f9b0 · 2026-08-30 10:07:41 -0700 · steve@designerwallcoverings.com

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 7eb582bc408dfdad2a95cabc89ec22abeb31f9b0
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date:   Sun Aug 30 10:07:41 2026 -0700

    Add apply-fix.mjs (restore-map-first, canary-batched, rollback) — needs write_inventory scope
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 apply-fix.mjs | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 99 insertions(+)

diff --git a/apply-fix.mjs b/apply-fix.mjs
new file mode 100644
index 0000000..2f0539f
--- /dev/null
+++ b/apply-fix.mjs
@@ -0,0 +1,99 @@
+#!/usr/bin/env node
+// TK-10965 — APPROVED remediation (Steve "go", 2026-08-30).
+// Make the $0 non-Sample variant NON-orderable by setting on_hand=0 at every location
+// that currently holds stock (keep inventoryPolicy=DENY, keep tracked). Does NOT touch
+// the $4.25 Sample variant. Fully reversible via the restore-map written before any write.
+//
+// Usage:
+//   node apply-fix.mjs --enumerate         # scan + write restore-map, NO writes
+//   node apply-fix.mjs --canary [N=50]     # fix first N, verify
+//   node apply-fix.mjs --all               # fix everything remaining
+//   node apply-fix.mjs --rollback FILE     # restore on_hand from a restore-map file
+import fs from 'node:fs';
+import path from 'node:path';
+
+const HERE = path.dirname(new URL(import.meta.url).pathname);
+const RUNS = path.join(HERE, 'runs'); fs.mkdirSync(RUNS, { recursive: true });
+const env = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8');
+const val = k => (env.match(new RegExp('^' + k + '=(.*)$', 'm')) || [])[1]?.trim();
+const DOM = val('SHOPIFY_STORE_DOMAIN'), TOK = val('SHOPIFY_ADMIN_TOKEN');
+const API = `https://${DOM}/admin/api/2024-10/graphql.json`;
+
+async function gql(q, v) {
+  for (let a = 0; a < 8; a++) {
+    const r = await fetch(API, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOK, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, variables: v }) });
+    const j = await r.json();
+    if (j.errors) { if (JSON.stringify(j.errors).includes('THROTTLED')) { await new Promise(s => setTimeout(s, 1800 * (a + 1))); continue; } throw new Error(JSON.stringify(j.errors)); }
+    return j.data;
+  }
+  throw new Error('gql retries');
+}
+
+const VAR_Q = `variants(first:20){nodes{id title price availableForSale inventoryPolicy inventoryItem{id tracked inventoryLevels(first:10){nodes{location{id} quantities(names:["on_hand"]){name quantity}}}}}}`;
+const isBad = v => !/sample/i.test(v.title || '') && Number(v.price) === 0 && v.availableForSale === true;
+
+async function enumerateBad() {
+  const out = []; const seen = new Set();
+  // PR via the quote-only tag search (efficient, matches the canary's 1281 set)
+  const searches = [
+    `status:active AND (tag:'quote-only' OR tag:'Quote Only' OR tag:'quote_only' OR tag:'Quote-Only')`,
+    `status:active AND vendor:'Fentucci Naturals'`,
+  ];
+  for (const q of searches) {
+    let after = null, pages = 0;
+    do {
+      const d = await gql(`query($q:String!,$after:String){products(first:100,query:$q,after:$after){pageInfo{hasNextPage endCursor} nodes{id title vendor ${VAR_Q}}}}`, { q, after });
+      for (const p of d.products.nodes) {
+        if (seen.has(p.id)) continue; seen.add(p.id);
+        const v = p.variants.nodes.find(isBad);
+        if (!v) continue;
+        const levels = (v.inventoryItem.inventoryLevels.nodes || [])
+          .map(l => ({ locationId: l.location.id, onHand: (l.quantities.find(x => x.name === 'on_hand')?.quantity) ?? 0 }))
+          .filter(l => l.onHand > 0);
+        out.push({ productId: p.id, title: p.title, vendor: p.vendor, variantId: v.id, inventoryItemId: v.inventoryItem.id, levels });
+      }
+      after = d.products.pageInfo.hasNextPage ? d.products.pageInfo.endCursor : null; pages++;
+    } while (after && pages < 60);
+  }
+  return out;
+}
+
+async function setOnHand(inventoryItemId, locationId, quantity) {
+  const d = await gql(`mutation($input:InventorySetQuantitiesInput!){inventorySetQuantities(input:$input){userErrors{field message code}}}`,
+    { input: { name: 'on_hand', reason: 'correction', ignoreCompareQuantity: true, quantities: [{ inventoryItemId, locationId, quantity }] } });
+  const errs = d.inventorySetQuantities.userErrors;
+  if (errs && errs.length) throw new Error(JSON.stringify(errs));
+}
+
+const stamp = () => new Date().toISOString().replace(/[:.]/g, '-');
+const arg = process.argv[2], arg2 = process.argv[3];
+
+if (arg === '--rollback') {
+  const map = JSON.parse(fs.readFileSync(arg2, 'utf8'));
+  let n = 0; for (const it of map.items) for (const l of it.levels) { await setOnHand(it.inventoryItemId, l.locationId, l.onHand); n++; }
+  console.log(`ROLLBACK: restored ${n} inventory levels from ${arg2}`);
+  process.exit(0);
+}
+
+const bad = await enumerateBad();
+const restorePath = path.join(RUNS, `restore-map-${stamp()}.json`);
+fs.writeFileSync(restorePath, JSON.stringify({ ts: new Date().toISOString(), count: bad.length, items: bad }, null, 2));
+console.log(`Enumerated ${bad.length} affected products. Restore-map: ${restorePath}`);
+const byV = {}; for (const b of bad) byV[b.vendor] = (byV[b.vendor] || 0) + 1;
+console.log('by vendor:', JSON.stringify(byV));
+
+if (arg === '--enumerate') { console.log('Enumerate-only. No writes.'); process.exit(0); }
+
+const limit = arg === '--canary' ? (Number(arg2) || 50) : bad.length;
+const target = bad.slice(0, limit);
+console.log(`Applying on_hand=0 to ${target.length} products (${arg})...`);
+let done = 0, fail = 0;
+for (const b of target) {
+  try {
+    if (!b.levels.length) { done++; continue; } // already 0 everywhere
+    for (const l of b.levels) await setOnHand(b.inventoryItemId, l.locationId, 0);
+    done++;
+    if (done % 100 === 0) console.log(`  ...${done}/${target.length}`);
+  } catch (e) { fail++; console.error(`  FAIL ${b.title}: ${e.message}`); }
+}
+console.log(`Applied: ${done} ok, ${fail} failed of ${target.length}. Restore-map: ${restorePath}`);

← 31761c3 Add multi-location caveat (Kimi second-model confirm) to rem  ·  back to Tk 10965 Zero Price Analysis  ·  add hardened zero-price orderable canary guard 3b4e9b8 →