← back to Dw Unbuyable Recovery Pilot
tk11042-quote-tag/repair.mjs
102 lines
#!/usr/bin/env node
/**
* TK-11042 — quote-CTA tag normalization executor. GATED: requires --live.
*
* Adds the single theme trigger tag `quotes` to products that already carry
* quote INTENT but not the trigger, so the existing "Request a Quote" CTA +
* "Order a Sample — $4.25" button render instead of a dead-end page.
*
* Adds ONE tag. Changes no price, no variant, no inventory, no status.
* Records the exact prior tags string per product so rollback restores it
* byte-for-byte.
*
* node repair.mjs --tranche=1 # dry-run (default)
* node repair.mjs --tranche=1 --limit=20 --live # pilot
* node repair.mjs --tranche=1 --live # full tranche (idempotent)
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const DIR = path.dirname(fileURLToPath(import.meta.url));
const args = process.argv.slice(2);
const LIVE = args.includes('--live');
const TRANCHE = (args.find(a => a.startsWith('--tranche=')) || '--tranche=1').split('=')[1];
const LIMIT = Number((args.find(a => a.startsWith('--limit=')) || '--limit=0').split('=')[1]) || 0;
const env = Object.fromEntries(
fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8')
.split('\n').filter(l => l.includes('='))
.map(l => [l.slice(0, l.indexOf('=')).trim(), l.slice(l.indexOf('=') + 1).trim()]));
const SHOP = (env.SHOPIFY_STORE_DOMAIN || env.SHOPIFY_STORE).replace(/^https?:\/\//, '');
const hdr = { 'X-Shopify-Access-Token': env.SHOPIFY_ADMIN_TOKEN, 'Content-Type': 'application/json' };
const REST = `https://${SHOP}/admin/api/2024-10`;
const sleep = ms => new Promise(r => setTimeout(r, ms));
const plan = JSON.parse(fs.readFileSync(path.join(DIR, 'data', 'plan.json'), 'utf8'));
let rows = plan[`tranche${TRANCHE}`];
if (!rows) { console.error(`no tranche${TRANCHE} in plan.json`); process.exit(1); }
if (LIMIT) rows = rows.slice(0, LIMIT);
const prestatePath = path.join(DIR, 'data', `prestate-t${TRANCHE}.jsonl`);
const already = new Set(fs.existsSync(prestatePath)
? fs.readFileSync(prestatePath, 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l).id) : []);
let acted = 0, skipped = 0, failed = 0;
for (const r of rows) {
// ---- VERIFY BEFORE ACT: re-read live, never trust the plan ----
const g = await fetch(`${REST}/products/${r.id}.json?fields=id,handle,tags,variants,status`, { headers: hdr });
if (!g.ok) { console.log('FAIL read', r.handle, g.status); failed++; continue; }
const p = (await g.json()).product;
const tags = (p.tags || '').split(',').map(s => s.trim()).filter(Boolean);
const lc = t => t.trim().toLowerCase();
if (p.status !== 'active') { console.log('skip', r.handle, 'not active'); skipped++; continue; }
if (tags.some(t => lc(t) === 'quotes')) { console.log('skip', r.handle, 'already has quotes'); skipped++; continue; }
if (already.has(r.id)) { console.log('skip', r.handle, 'already in prestate'); skipped++; continue; }
// must still be non-sellable — if someone priced it since the plan, leave it alone
if ((p.variants || []).some(v => !/sample/i.test(v.title || '') && Number(v.price) > 4.50))
{ console.log('skip', r.handle, 'now sellable'); skipped++; continue; }
// must still carry the intent that justified the change
const intentOk = TRANCHE === '1'
? tags.some(t => ['quote-only', 'contact-for-price'].includes(lc(t)))
: tags.some(t => lc(t) === 'needs-price');
if (!intentOk) { console.log('skip', r.handle, 'intent tag gone'); skipped++; continue; }
// theme needs a detectable Sample variant for the sample button
if (!(p.variants || []).some(v => /-sample/i.test(v.sku || '') || v.title === 'Sample'))
{ console.log('skip', r.handle, 'no Sample variant'); skipped++; continue; }
const tagsBefore = p.tags || '';
const tagsAfter = tagsBefore ? `${tagsBefore}, quotes` : 'quotes';
if (!LIVE) { console.log('WOULD TAG', r.handle, `(+quotes)`); acted++; continue; }
const u = await fetch(`${REST}/products/${r.id}.json`, {
method: 'PUT', headers: hdr,
body: JSON.stringify({ product: { id: r.id, tags: tagsAfter } }) });
if (!u.ok) { console.log('FAIL write', r.handle, u.status, await u.text()); failed++; await sleep(600); continue; }
fs.appendFileSync(prestatePath, JSON.stringify({
ts: new Date().toISOString(), ticket: 'TK-11042', tranche: TRANCHE,
id: r.id, handle: r.handle, vendor: r.vendor, tagsBefore, tagsAfter }) + '\n');
acted++; console.log('tagged', r.handle);
await sleep(600);
}
console.log(`\n${LIVE ? 'LIVE' : 'DRY-RUN'} tranche${TRANCHE}: acted=${acted} skipped=${skipped} failed=${failed}`);
// Reversible-ledger row (CLAUDE.md gate-temperature rule): one summary entry per LIVE run
// so /rollback-runbook can offer the byte-for-byte undo. Loud on failure, never silent.
if (LIVE && acted > 0) {
const { spawnSync } = await import('node:child_process');
const r = spawnSync('node', [process.env.HOME + '/.claude/yolo-queue/executed-reversible/log-exec.mjs',
'--agent', process.env.TK_AGENT || 'tk11042-repair', '--ticket', 'TK-11042',
'--action', `add 'quotes' tag to ${acted} tranche${TRANCHE} products (quote-CTA dead-end fix)`,
'--blast', String(acted),
'--undo', `cd ${DIR} && node rollback-all.mjs --tranche=${TRANCHE} --live`,
'--verify', `wc -l ${prestatePath} # expect ${acted}+ rows; storefront PDP shows 'Request a Quote'`],
{ encoding: 'utf8' });
if (r.status !== 0) console.error('LEDGER WRITE FAILED — record undo manually:', r.stderr || r.stdout);
else console.log('ledger row written (executed-reversible/ledger.jsonl)');
}
console.log(LIVE ? `prestate -> ${prestatePath}\nrollback: node rollback-all.mjs --tranche=${TRANCHE} --live` : 'nothing was written.');