← 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); });