← 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.');