← back to Dw Unbuyable Recovery Pilot
tk10978-rebelwalls/archive-duplicates.mjs
95 lines
#!/usr/bin/env node
// TK-10978 — Rebel Walls stale RS-orphan ARCHIVE (customer-facing Shopify write).
//
// FINDING (verified end-to-end, 2026-09-02): the store re-imported Rebel Walls
// under a new R-series and made THOSE buyable, orphaning the old RS-series as
// ACTIVE-but-unbuyable (sample-only). 454 RS-orphans have a buyable R-twin at
// IDENTICAL mfr digits (RS15372<->R15372, confirmed: R15372 is ACTIVE with a live
// per-m2 variant + $4.25 sample). Those RS-orphans are STALE DUPLICATES.
//
// CORRECT remediation = ARCHIVE the RS-orphan (its buyable R-twin already serves
// customers), NOT make it buyable (that would create 454 customer-facing dupes).
//
// VERIFY-BEFORE-ACT (per product, live, at write time — never trust the CSV):
// 1. GET the RS-orphan; must be status ACTIVE.
// 2. RS-orphan must be genuinely unbuyable: NO non-Sample variant
// (every variant option1 ILIKE 'Sample' / '%-Sample' sku) — never archive
// a product that has a real sellable variant.
// 3. GET the R-twin (from CSV twin_pid); must be status ACTIVE and HAVE a
// buyable non-Sample variant. If the twin isn't genuinely buyable -> SKIP
// (do not orphan the design).
// 4. RS-orphan digits == R-twin digits (re-derive, don't trust the row).
// Any guard fail -> SKIP + log; never an ambiguous archive.
//
// Reversibility FIRST: prestate.jsonl {pid, prior_status} written BEFORE the write.
// undo = set status back to prior_status (active). Ledgered per product.
// Action: productUpdate status -> 'archived' (default; --status=draft alternative).
//
// Usage: node archive-duplicates.mjs [--limit=N] [--only=<pid>] [--status=archived|draft] [--live]
// default = DRY-RUN (no writes). --live requires Steve's explicit go.
import { readFileSync, appendFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { execSync } from 'child_process';
const TOKEN = execSync(`grep -E '^SHOPIFY_ADMIN_TOKEN=' ${process.env.HOME}/Projects/secrets-manager/.env|cut -d= -f2-`).toString().trim();
const DOMAIN = 'designer-laboratory-sandbox.myshopify.com', API = '2024-10';
const REST = `https://${DOMAIN}/admin/api/${API}`;
const hdr = { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' };
const LIVE = process.argv.includes('--live');
const ONLY = (process.argv.find(a => a.startsWith('--only=')) || '').split('=')[1];
const LIMIT = Number((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || 0);
const STATUS = (process.argv.find(a => a.startsWith('--status=')) || '').split('=')[1] || 'archived';
const dataDir = new URL('./data', import.meta.url).pathname; mkdirSync(dataDir, { recursive: true });
const PRESTATE = `${dataDir}/archive-prestate.jsonl`;
const SKIPS = `${dataDir}/archive-skips.json`;
const LEDGER = `${process.env.HOME}/.claude/yolo-queue/executed-reversible/ledger.jsonl`;
const csv = readFileSync(`${dataDir}/archive-454-duplicates.csv`, 'utf8').trim().split('\n');
csv.shift(); // header
let rows = csv.map(l => { const m = l.match(/^(\d+),(".*?"|[^,]*),([^,]*),([^,]*),(\d+)$/); return m ? { pid:m[1], title:m[2].replace(/^"|"$/g,''), rs_sku:m[3], twin_sku:m[4], twin_pid:m[5] } : null; }).filter(Boolean);
if (ONLY) rows = rows.filter(r => r.pid === ONLY);
if (LIMIT > 0) rows = rows.slice(0, LIMIT);
const digits = s => (String(s||'').match(/\d+/)||[''])[0];
const isSample = v => /sample/i.test(v.option1||'') || /-sample$/i.test(v.sku||'');
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function api(path, opts = {}) {
for (let a=0;a<6;a++){ const res=await fetch(`${REST}${path}`,{headers:hdr,...opts});
if(res.status===429){await sleep(2500);continue;} const t=await res.text(); let j; try{j=JSON.parse(t);}catch{j={_raw:t};}
return {status:res.status,json:j}; } return {status:429,json:{}};
}
let acted=0, skipped=0, failed=0; const skips=[];
for (const r of rows) {
// GUARD 1/2 — RS-orphan live
const o = await api(`/products/${r.pid}.json`);
if (o.status!==200 || !o.json.product) { skips.push({pid:r.pid,reason:`fetch ${o.status}`,title:r.title}); skipped++; continue; }
const op = o.json.product;
if ((op.status||'').toLowerCase()!=='active') { skips.push({pid:r.pid,reason:`orphan status=${op.status}`,title:r.title}); skipped++; continue; }
const oNonSample = (op.variants||[]).filter(v=>!isSample(v));
if (oNonSample.length>0) { skips.push({pid:r.pid,reason:`orphan HAS sellable variant (${oNonSample.map(v=>v.option1).join('|')}) — not a pure orphan`,title:r.title}); skipped++; continue; }
// GUARD 3/4 — R-twin genuinely buyable
const t = await api(`/products/${r.twin_pid}.json`);
if (t.status!==200 || !t.json.product) { skips.push({pid:r.pid,reason:`twin fetch ${t.status}`,title:r.title}); skipped++; continue; }
const tp = t.json.product;
if ((tp.status||'').toLowerCase()!=='active') { skips.push({pid:r.pid,reason:`twin status=${tp.status}`,title:r.title}); skipped++; continue; }
if ((tp.variants||[]).filter(v=>!isSample(v)).length===0) { skips.push({pid:r.pid,reason:`twin NOT buyable (no sellable variant)`,title:r.title}); skipped++; continue; }
if (digits(op.variants?.[0]?.sku) && digits(r.rs_sku)!==digits(r.twin_sku)) { skips.push({pid:r.pid,reason:`digit mismatch rs=${r.rs_sku} twin=${r.twin_sku}`,title:r.title}); skipped++; continue; }
if (!LIVE) { console.log(`DRY archive ${r.pid} "${op.title}" (dup of buyable ${r.twin_sku} ${r.twin_pid}) -> ${STATUS}`); acted++; continue; }
// reversibility FIRST
appendFileSync(PRESTATE, JSON.stringify({ ts:new Date().toISOString(), pid:r.pid, title:op.title, prior_status:op.status, twin_pid:r.twin_pid, twin_sku:r.twin_sku })+'\n');
const up = await api(`/products/${r.pid}.json`, { method:'PUT', body: JSON.stringify({ product: { id: Number(r.pid), status: STATUS } }) });
if (up.status!==200) { skips.push({pid:r.pid,reason:`archive ${up.status}`,detail:JSON.stringify(up.json).slice(0,160),title:op.title}); failed++; continue; }
const undo = `curl -s -X PUT "${REST}/products/${r.pid}.json" -H "X-Shopify-Access-Token: $SHOPIFY_ADMIN_TOKEN" -H 'Content-Type: application/json' -d '{"product":{"id":${r.pid},"status":"${op.status}"}}'`;
appendFileSync(LEDGER, JSON.stringify({ ts:new Date().toISOString(), agent:'run-now-rebelwalls', ticket:'TK-10978',
action:`archive stale RS-orphan ${r.pid} "${op.title}" (${r.rs_sku}) -> ${STATUS}; dup of ACTIVE+buyable twin ${r.twin_sku} ${r.twin_pid}`,
product_id:r.pid, prior_status:op.status, blast_radius:1, undo_cmd:undo,
verify:`curl -s "${REST}/products/${r.pid}.json" -H "X-Shopify-Access-Token: $SHOPIFY_ADMIN_TOKEN"|jq '.product.status'` })+'\n');
acted++; console.log(`ARCHIVED ${r.pid} "${op.title}" -> ${STATUS} (twin ${r.twin_sku} buyable)`);
await sleep(600);
}
writeFileSync(SKIPS, JSON.stringify(skips,null,2));
console.log(`\n${LIVE?'LIVE':'DRY'} — acted ${acted}, skipped ${skipped}, failed ${failed} (of ${rows.length}); status target=${STATUS}`);