← back to Dw Yolo Loop

thibaut-mfr-fix/push-thibaut-live.js

104 lines

#!/usr/bin/env node
/**
 * Direct Shopify Admin REST pusher for the Thibaut mfr-SKU fix (DTD verdict A, 2026-06-11).
 * Bypasses the sandbox-targeted shopify_api_queue worker; writes straight to the LIVE store.
 *
 * Source of truth: PG table thibaut_mfr_fix_staging (fix_type 'tag'|'title', old_value, new_value, shopify_id).
 * Each PUT merges ONLY the title or tags field — never variants/images/price.
 *
 * Usage:
 *   SHOPIFY_ADMIN_TOKEN=shpat_xxx node push-thibaut-live.js            # dry-run: GET first 3, print planned change
 *   SHOPIFY_ADMIN_TOKEN=shpat_xxx node push-thibaut-live.js --apply    # fire all, 2 req/sec, log per-row to staging
 *   ...optional: SHOPIFY_STORE=designerwallcoverings.myshopify.com (default) · --only=tag|title · --limit=N
 */
const https = require('https');
const { Pool } = require('pg');

const STORE = process.env.SHOPIFY_STORE || 'designerwallcoverings.myshopify.com';
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || process.env.SHOPIFY_PRODUCT_TOKEN;
const API   = process.env.SHOPIFY_API_VERSION || '2024-10';
const DB    = process.env.DATABASE_URL || 'postgresql://localhost/dw_unified';
const APPLY = process.argv.includes('--apply');
const ONLY  = (process.argv.find(a => a.startsWith('--only=')) || '').split('=')[1] || null;
const LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || '0', 10);

if (!TOKEN) { console.error('FATAL: set SHOPIFY_ADMIN_TOKEN env var (never hard-code it).'); process.exit(1); }

const pool = new Pool({ connectionString: DB });
const sleep = ms => new Promise(r => setTimeout(r, ms));

function shopify(method, path, body) {
  return new Promise((resolve, reject) => {
    const data = body ? JSON.stringify(body) : null;
    const req = https.request({
      hostname: STORE, path: `/admin/api/${API}${path}`, method,
      headers: {
        'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json',
        ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}),
      },
    }, res => {
      let buf = ''; res.on('data', c => buf += c);
      res.on('end', () => { let j; try { j = JSON.parse(buf); } catch { j = buf; }
        resolve({ status: res.statusCode, body: j,
                  callLimit: res.headers['x-shopify-shop-api-call-limit'] }); });
    });
    req.on('error', reject);
    if (data) req.write(data);
    req.end();
  });
}

(async () => {
  const where = ONLY ? `AND fix_type = '${ONLY === 'title' ? 'title' : 'tag'}'` : '';
  const lim = LIMIT > 0 ? `LIMIT ${LIMIT}` : '';
  const { rows } = await pool.query(`
    SELECT id, shopify_id, fix_type, mfr_sku, old_value, new_value, status
    FROM thibaut_mfr_fix_staging
    WHERE status IN ('queued','staged','failed')
      AND shopify_id ~ '^gid://shopify/Product/[0-9]+$' ${where}
    ORDER BY fix_type, id ${lim}`);
  console.log(`Store: ${STORE} · token …${TOKEN.slice(-4)} · rows to push: ${rows.length} · mode: ${APPLY ? 'APPLY' : 'DRY-RUN'}`);

  if (!APPLY) {
    // confirm domain+token + show first 3 planned changes
    for (const r of rows.slice(0, 3)) {
      const pid = r.shopify_id.split('/').pop();
      const got = await shopify('GET', `/products/${pid}.json?fields=id,title`);
      const field = r.fix_type === 'title' ? 'title' : 'tags';
      console.log(`\n[${r.fix_type}] product ${pid}  (GET HTTP ${got.status})`);
      console.log(`   live title : ${got.body?.product?.title ?? '(not found / auth fail)'}`);
      console.log(`   will set ${field}: ${r.new_value.slice(0, 160)}${r.new_value.length > 160 ? '…' : ''}`);
      await sleep(550);
    }
    console.log(`\nDRY-RUN only. Re-run with --apply to push all ${rows.length}.`);
    await pool.end(); return;
  }

  let ok = 0, fail = 0;
  for (const r of rows) {
    const pid = r.shopify_id.split('/').pop();
    const field = r.fix_type === 'title' ? 'title' : 'tags';
    try {
      const res = await shopify('PUT', `/products/${pid}.json`,
        { product: { id: Number(pid), [field]: r.new_value } });
      if (res.status === 200) {
        ok++;
        await pool.query(`UPDATE thibaut_mfr_fix_staging SET status='pushed' WHERE id=$1 AND fix_type=$2`, [r.id, r.fix_type]);
      } else {
        fail++;
        await pool.query(`UPDATE thibaut_mfr_fix_staging SET status='failed' WHERE id=$1 AND fix_type=$2`, [r.id, r.fix_type]);
        console.error(`  FAIL ${r.fix_type} ${pid}: HTTP ${res.status} ${JSON.stringify(res.body).slice(0,140)}`);
      }
      if ((ok + fail) % 100 === 0) console.log(`  ...${ok + fail}/${rows.length} (ok=${ok} fail=${fail})`);
      // throttle ~2 req/sec; back off if near the 40-call bucket
      const [used] = (res.callLimit || '0/40').split('/').map(Number);
      await sleep(used > 30 ? 1500 : 520);
    } catch (e) {
      fail++; console.error(`  ERR ${r.fix_type} ${pid}: ${e.message}`);
      await sleep(520);
    }
  }
  console.log(`\nDONE. pushed=${ok} failed=${fail} of ${rows.length}`);
  await pool.end();
})().catch(e => { console.error(e); process.exit(1); });