← 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 →