← back to Dw Add Sellable Variant Tk10902
tk10825-unpublish.mjs
114 lines
#!/usr/bin/env node
// TK-10825 — Close the $0-free-checkout exposure on 1,281 quote-only Phillipe Romano products.
// PRIMARY REMEDIATION = OPTION A: unpublish from Online Store sales channel (published_at=null).
// Product stays ACTIVE in admin; no longer orderable on storefront; fully reversible (re-publish).
// GATED — customer-facing Shopify write. DEFAULT = DRY-RUN. --live performs writes. Steve-gated.
// Reversible + ledgered. Verify-before-write. NO EMAIL. $0 (Shopify Admin API).
//
// For each product:
// 1. Re-read LIVE. HARD-STOP (skip+log) unless it STILL matches the verified defect signature:
// - status == 'active'
// - published_at set (currently published)
// - has a variant priced exactly 0.00
// - carries the 'quote-only' tag
// - vendor == 'Phillipe Romano'
// (belt-and-suspenders so a since-fixed product is never touched)
// 2. Record published_at (the exact restore value) BEFORE the write.
// 3. PUT product published=false (unpublish from Online Store).
// 4. Ledger product_id + prior published_at + exact re-publish undo command.
//
// Usage: node tk10825-unpublish.mjs <ids.csv> [--limit N] [--live]
// ids.csv = one product_id per line (first CSV field), as produced by the ticket export.
import fs from 'fs';
import { execSync } from 'child_process';
const DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';
const TOKEN = execSync(`grep -E '^SHOPIFY_ADMIN_TOKEN=' ${process.env.HOME}/Projects/secrets-manager/.env | cut -d= -f2-`).toString().trim();
if (!TOKEN) { console.error('NO SHOPIFY TOKEN'); process.exit(1); }
const LEDGER = `${process.env.HOME}/.claude/yolo-queue/executed-reversible/ledger.jsonl`;
const RUNLOG = `${process.cwd()}/tk10825-run-log.jsonl`;
const args = process.argv.slice(2);
const file = args[0];
const LIVE = args.includes('--live');
const limIdx = args.indexOf('--limit');
const LIMIT = limIdx >= 0 ? parseInt(args[limIdx + 1], 10) : Infinity;
const base = `https://${DOMAIN}/admin/api/${API}`;
const hdr = { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' };
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function shopify(path, opts = {}) {
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(`${base}${path}`, { headers: hdr, ...opts });
if (res.status === 429) { await sleep(2500); continue; }
const body = await res.text();
let json; try { json = JSON.parse(body); } catch { json = { _raw: body }; }
return { status: res.status, json };
}
return { status: 429, json: { error: 'rate-limited' } };
}
function appendJsonl(path, obj) {
fs.mkdirSync(path.substring(0, path.lastIndexOf('/')), { recursive: true });
fs.appendFileSync(path, JSON.stringify(obj) + '\n');
}
const ids = fs.readFileSync(file, 'utf8').split('\n')
.map(l => l.trim().split(',')[0]).filter(Boolean);
const batch = ids.slice(0, LIMIT);
let done = 0, skipped = 0;
const skips = [];
console.log(`\n=== ${LIVE ? 'LIVE UNPUBLISH' : 'DRY-RUN'} — ${batch.length} products (of ${ids.length}) ===\n`);
for (const pid of batch) {
const { status, json } = await shopify(`/products/${pid}.json?fields=id,title,status,published_at,vendor,tags,variants`);
if (status !== 200 || !json.product) {
skipped++; skips.push({ pid, reason: `fetch_failed_${status}` });
console.log(`SKIP ${pid} fetch_failed_${status}`); continue;
}
const p = json.product;
const tags = (p.tags || '').toLowerCase();
const hasZero = (p.variants || []).some(v => parseFloat(v.price) === 0);
// verify-before-write: STILL the defect?
if (p.status !== 'active') { skipped++; skips.push({ pid, reason: `not_active(${p.status})`, title: p.title }); console.log(`SKIP ${pid} not_active`); continue; }
if (!p.published_at) { skipped++; skips.push({ pid, reason: 'already_unpublished', title: p.title }); console.log(`SKIP ${pid} already_unpublished`); continue; }
if (!hasZero) { skipped++; skips.push({ pid, reason: 'no_zero_variant', title: p.title }); console.log(`SKIP ${pid} no_zero_variant`); continue; }
if (!tags.includes('quote-only')) { skipped++; skips.push({ pid, reason: 'not_quote_only', title: p.title }); console.log(`SKIP ${pid} not_quote_only`); continue; }
if ((p.vendor || '') !== 'Phillipe Romano') { skipped++; skips.push({ pid, reason: `vendor=${p.vendor}`, title: p.title }); console.log(`SKIP ${pid} vendor=${p.vendor}`); continue; }
const priorPublishedAt = p.published_at;
if (!LIVE) {
console.log(`WOULD UNPUBLISH ${pid} "${p.title}" (was published_at=${priorPublishedAt})`);
done++;
appendJsonl(RUNLOG, { ts: new Date().toISOString(), mode: 'dry', action: 'would_unpublish', pid, title: p.title, prior_published_at: priorPublishedAt });
continue;
}
const upd = await shopify(`/products/${pid}.json`, { method: 'PUT', body: JSON.stringify({ product: { id: Number(pid), published: false } }) });
if (upd.status !== 200) {
skipped++; skips.push({ pid, reason: `update_failed_${upd.status} ${JSON.stringify(upd.json).slice(0,160)}`, title: p.title });
console.log(`SKIP ${pid} update_failed_${upd.status}`); continue;
}
done++;
const undo = `curl -s -X PUT "https://${DOMAIN}/admin/api/${API}/products/${pid}.json" -H "X-Shopify-Access-Token: $SHOPIFY_ADMIN_TOKEN" -H "Content-Type: application/json" -d '{"product":{"id":${pid},"published":true}}'`;
appendJsonl(LEDGER, {
ts: new Date().toISOString(), agent: 'yolofx-TK-10825', ticket: 'TK-10825',
action: `unpublished $0 quote-only product ${pid} "${p.title}" (Phillipe Romano) from Online Store`,
product_id: pid, prior_published_at: priorPublishedAt,
blast_radius: 1, undo_cmd: undo,
verify: `curl -s "https://${DOMAIN}/admin/api/${API}/products/${pid}.json?fields=id,status,published_at" -H "X-Shopify-Access-Token: $SHOPIFY_ADMIN_TOKEN"`
});
appendJsonl(RUNLOG, { ts: new Date().toISOString(), mode: 'live', action: 'unpublished', pid, title: p.title, prior_published_at: priorPublishedAt });
console.log(`UNPUBLISHED ${pid} "${p.title}"`);
await sleep(600);
}
fs.writeFileSync(`${process.cwd()}/tk10825-skips.json`, JSON.stringify(skips, null, 1));
console.log(`\n=== DONE: done=${done} skipped=${skipped} (skips -> tk10825-skips.json) ===`);