← 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) ===`);